agentera 3.0.0-dev.79 → 3.0.0-dev.80

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.
@@ -23,6 +23,9 @@ import { normalizeTodoOwnerCorrectionEvidence, planTodoOwnerCorrection, planTodo
23
23
  import { readTodoMarkdown, renderManagedMarkdown } from "./todoMarkdownProjection.js";
24
24
  import { inactiveTodoActivationSafety, rejectUnsafeInactiveTodoActivation, unsafeInactiveDuplicateDiagnosis } from "./todoActivationSafety.js";
25
25
  import { assertTodoSeverityHeadingStructure, todoSeveritySectionForHeading } from "./todoSeverityHeadings.js";
26
+ import { parseTodoUpdateBatch, todoUpdateBatchEffectSha256 } from "./todoUpdateBatch.js";
27
+ import { parseTodoCreateBatch, resolveTodoCreateBatchRecords, todoCreateBatchEffectSha256 } from "./todoCreateBatch.js";
28
+ import { matchesTodoTransitionBatchPostState, parseTodoTransitionBatch, todoTransitionBatchPostStateSha256 } from "./todoTransitionBatch.js";
26
29
  const ID = /^[a-z]{10}$/;
27
30
  const SHA256 = /^[a-f0-9]{64}$/;
28
31
  const TODO = { artifact: "todo", boundary: "todo_item", order: "severity_then_status_then_markdown_order_then_id" };
@@ -411,7 +414,7 @@ function importMarkdown(record, row) {
411
414
  result.status = row.item.status;
412
415
  return result;
413
416
  }
414
- function withBaseline(record, value) { const publicValue = Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)); return { ...record, reconciliation: { schema_version: RECONCILIATION_VERSION, public: publicValue } }; }
417
+ function withBaseline(record, value) { const publicValue = Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)); const transitionBatch = mapping(record.reconciliation) && mapping(record.reconciliation.transition_batch) ? structuredClone(record.reconciliation.transition_batch) : null; return { ...record, reconciliation: { schema_version: RECONCILIATION_VERSION, public: publicValue, ...(transitionBatch ? { transition_batch: transitionBatch } : {}) } }; }
415
418
  function inactiveTodoMutation() { reject({ class: "conflict", message: "TODO reconciliation is inactive; ordinary TODO mutations cannot activate it implicitly", syntax: TODO_ACTIVATION_PREVIEW_COMMAND, example: TODO_ACTIVATION_APPLY_COMMAND, recovery: `Run exactly '${TODO_ACTIVATION_PREVIEW_COMMAND}', review every reported effect, then run exactly '${TODO_ACTIVATION_APPLY_COMMAND}'; no state was changed.` }); }
416
419
  function activationEnvelope(effect, dryRun, replay, transactionId, targets, recovered) { return { schemaVersion: "agentera.stateWrite.v1", command: "state todo activate", status: "pass", path: TODO_RECONCILIATION_ACTIVATION_PATH, artifact: "todo", operation: { verb: "activate", dry_run: dryRun, idempotent_replay: replay }, validation: { status: "pass", violations: [] }, activation: effect, apply_command: TODO_ACTIVATION_APPLY_COMMAND.replace("EFFECT_SHA256", String(effect.effect_sha256)), reconciliation: { transaction_id: transactionId, targets, recovered } }; }
417
420
  function repairEnvelope(effect, dryRun, replay, transactionId, targets, recovered) { return { schemaVersion: "agentera.stateWrite.v1", command: "state todo repair", status: "pass", path: TODO_RECONCILIATION_ACTIVATION_PATH, artifact: "todo", operation: { verb: "repair", dry_run: dryRun, idempotent_replay: replay }, validation: { status: "pass", violations: [] }, repair: effect, apply_command: TODO_REPAIR_APPLY_COMMAND.replace("EFFECT_SHA256", String(effect.effect_sha256)), reconciliation: { transaction_id: transactionId, targets, recovered } }; }
@@ -671,6 +674,9 @@ export function mutateTodoDocsEntity(req, options = {}) {
671
674
  const todoBinding = artifact === "todo" ? todoReconciliationBinding(req.projectRoot, sourceRoot) : null;
672
675
  const correctingOwners = artifact === "todo" && req.spec.verb === "correct-owners";
673
676
  const ownerEvidence = correctingOwners ? normalizeTodoOwnerCorrectionEvidence(req.input ?? {}) : null;
677
+ const batch = artifact === "todo" && req.spec.verb === "update" ? parseTodoUpdateBatch(req.input) : null;
678
+ const createBatch = artifact === "todo" && req.spec.verb === "create" ? parseTodoCreateBatch(req.input) : null;
679
+ const transitionBatch = artifact === "todo" ? parseTodoTransitionBatch(req.input, req.spec.verb) : null;
674
680
  const pending = todoBinding ? inspectTodoReconciliation(pinnedRoot, todoBinding) : [];
675
681
  if (artifact === "todo")
676
682
  assertTodoSeverityHeadingStructure(readTodoMarkdown(todoPublicPath(pinnedRoot, sourceRoot)).text);
@@ -690,13 +696,15 @@ export function mutateTodoDocsEntity(req, options = {}) {
690
696
  inactiveTodoMutation();
691
697
  if (req.dryRun && pending.length)
692
698
  reject({ class: "conflict", message: `TODO reconciliation transaction '${pending[0]}' requires recovery before dry-run`, recovery: "Retry the exact TODO mutation without --dry-run once to complete recovery; this dry-run changed no state." });
693
- const createRequest = artifact === "todo" && req.spec.verb === "create" ? mutationRecord(req, undefined) : null;
699
+ const createRequest = artifact === "todo" && req.spec.verb === "create" && !createBatch ? mutationRecord(req, undefined) : null;
694
700
  const createRequestSha256 = createRequest ? todoCreateRequestSha256(createRequest) : undefined;
695
701
  const recoveryReceipts = todoBinding && !req.dryRun
696
702
  ? recoverTodoReconciliation(context, sourceRoot, todoBinding, {
697
703
  createRequestSha256,
698
704
  ...(["activate", "repair", "correct-owners"].includes(req.spec.verb) ? { activationEffectSha256: String(req.values.effect_sha256) } : {}),
699
705
  ...(correctingOwners ? { ownerMappingSha256: ownerEvidence.sha256 } : {}),
706
+ ...(batch || transitionBatch ? { updateBatch: { effectSha256: String(req.values.effect_sha256 ?? ""), input: req.input } } : {}),
707
+ ...(createBatch ? { createBatch: { effectSha256: String(req.values.effect_sha256 ?? ""), input: req.input } } : {}),
700
708
  beforeCommit: () => assertState(pinnedRoot, sourceRoot, sourceBinding),
701
709
  })
702
710
  : [];
@@ -711,6 +719,15 @@ export function mutateTodoDocsEntity(req, options = {}) {
711
719
  const created = selectedById(entities, "todo", recoveredCreate.create.created_id);
712
720
  return { ...envelope("state todo create", { id: created.id, path: created.path, replay: true }, "todo", created.record, false), reconciliation: { transaction_id: recoveredCreate.transaction_id, targets: 0, recovered } };
713
721
  }
722
+ const recoveredBatch = recoveryReceipts.find((receipt) => receipt.update_batch_effect_sha256);
723
+ if (recoveredBatch?.update_batch_effect_sha256 && (batch || transitionBatch)) {
724
+ const verb = transitionBatch?.verb ?? "update";
725
+ const entries = transitionBatch?.entries ?? batch;
726
+ return { schemaVersion: "agentera.stateWrite.v1", command: `state todo ${verb}`, status: "pass", artifact: "todo", records: entries.map(({ id }) => ({ id, record: selectedById(entities, "todo", id).record })), operation: { verb, dry_run: false, idempotent_replay: true }, validation: { status: "pass", violations: [] }, effect_sha256: recoveredBatch.update_batch_effect_sha256, apply_command: null, reconciliation: { transaction_id: recoveredBatch.transaction_id, targets: recoveredBatch.target_count, recovered } };
727
+ }
728
+ const recoveredCreateBatch = recoveryReceipts.find((receipt) => receipt.create_batch);
729
+ if (recoveredCreateBatch?.create_batch && createBatch)
730
+ return { schemaVersion: "agentera.stateWrite.v1", command: "state todo create", status: "pass", artifact: "todo", records: Object.entries(recoveredCreateBatch.create_batch.local_refs).map(([local_ref, id]) => ({ local_ref, id, record: selectedById(entities, "todo", id).record })), local_refs: recoveredCreateBatch.create_batch.local_refs, operation: { verb: "create", dry_run: false, idempotent_replay: true }, validation: { status: "pass", violations: [] }, effect_sha256: recoveredCreateBatch.create_batch.effect_sha256, apply_command: null, reconciliation: { transaction_id: recoveredCreateBatch.transaction_id, targets: recoveredCreateBatch.target_count, recovered } };
714
731
  const publicFile = todoPublicPath(pinnedRoot, sourceRoot);
715
732
  const publicRelative = todoBinding.publicPath;
716
733
  const publicExists = fs.existsSync(publicFile);
@@ -868,6 +885,199 @@ export function mutateTodoDocsEntity(req, options = {}) {
868
885
  const scan = managedRows(markdown, activation, todoEntities);
869
886
  const rows = scan.rows;
870
887
  const reconciled = reconcileTodoRecords(todoEntities, rows, activating);
888
+ if (createBatch) {
889
+ if (!req.dryRun && (!req.values.confirmed || !SHA256.test(String(req.values.effect_sha256 ?? ""))))
890
+ reject({ class: "invalid_request", message: "todo create batch apply requires its preview effect SHA-256 and --yes", recovery: "Run the same batch input with --dry-run, review it, then repeat that input with the returned --effect-sha256 value and --yes; no state was changed." });
891
+ const inputSha256 = createHash("sha256").update(canonicalRecordJson(req.input)).digest("hex");
892
+ const prior = todoEntities.filter(({ record }) => mapping(record?.reconciliation) && mapping(record.reconciliation.create_batch) && record.reconciliation.create_batch.input_sha256 === inputSha256);
893
+ if (prior.length) {
894
+ const createReceipt = prior[0].record.reconciliation.create_batch;
895
+ const localRefs = createReceipt.local_refs;
896
+ if (prior.length !== createBatch.length || prior.some(({ id, record }) => canonicalRecordJson(record.reconciliation.create_batch) !== canonicalRecordJson(createReceipt) || !Object.values(localRefs).includes(id)) || req.values.effect_sha256 !== createReceipt.effect_sha256)
897
+ reject({ class: "conflict", message: "existing TODO create batch receipt is incomplete or does not match this effect authorization", recovery: "Restore the complete original create batch result or use its exact input and effect SHA-256; no state was changed." });
898
+ return { schemaVersion: "agentera.stateWrite.v1", command: "state todo create", status: "pass", artifact: "todo", records: createBatch.map(({ local_ref }) => { const id = localRefs[local_ref]; return { local_ref, id, record: selectedById(todoEntities, "todo", id).record }; }), local_refs: localRefs, operation: { verb: "create", dry_run: false, idempotent_replay: true }, validation: { status: "pass", violations: [] }, effect_sha256: createReceipt.effect_sha256, apply_command: null, reconciliation: { transaction_id: null, targets: 0, recovered } };
899
+ }
900
+ const allocated = new Set();
901
+ const localIds = new Map();
902
+ for (const { local_ref } of createBatch) {
903
+ let salt = 0;
904
+ let id;
905
+ do {
906
+ const digest = createHash("sha256").update(inputSha256).update("\0").update(local_ref).update("\0").update(String(salt++)).digest();
907
+ id = Array.from(digest.subarray(0, 10), (byte) => String.fromCharCode(97 + byte % 26)).join("");
908
+ try {
909
+ id = allocateEntityId(context.pinnedPath(), () => id, sourceRoot);
910
+ }
911
+ catch (error) {
912
+ if (!String(error.message).includes("could not allocate"))
913
+ throw error;
914
+ continue;
915
+ }
916
+ } while (allocated.has(id));
917
+ allocated.add(id);
918
+ localIds.set(local_ref, id);
919
+ }
920
+ const resolved = resolveTodoCreateBatchRecords(createBatch, localIds);
921
+ for (const { local_ref, record } of resolved) {
922
+ const id = localIds.get(local_ref);
923
+ reconciled.records.set(id, mutationRecord({ ...req, input: record, values: {}, callerPayload: record }, undefined));
924
+ reconciled.visible.add(id);
925
+ }
926
+ const referenceEntities = [...reconciled.records].map(([todoId, record]) => ({ boundary: TODO.boundary, id: todoId, record }));
927
+ for (const [todoId, record] of reconciled.records) {
928
+ const violations = recordViolations("todo", record, sourceRoot);
929
+ if (violations.length)
930
+ reject({ class: "schema_violation", message: "todo entity input is invalid", violations, recovery: "Correct the batch record and preview the complete batch again; no state was changed." });
931
+ if (record.readiness !== undefined)
932
+ assertTodoReferences(todoId, record, referenceEntities);
933
+ }
934
+ const visibleRecords = new Map([...reconciled.records].filter(([todoId]) => reconciled.visible.has(todoId)));
935
+ const rendered = renderManagedMarkdown(markdown, visibleRecords, rows);
936
+ const activationBytesAfter = activation ? loadedActivation.bytes.toString("utf8") : todoReconciliationActivationBytes(scan.retainedLegacyRows);
937
+ const activationAfter = activation ?? JSON.parse(activationBytesAfter);
938
+ const finalRows = managedRows(rendered, activationAfter, [...todoEntities, ...[...localIds.values()].map((id) => ({ boundary: TODO.boundary, id, record: reconciled.records.get(id) }))]).rows;
939
+ for (const [todoId, record] of reconciled.records) {
940
+ const row = finalRows.get(todoId);
941
+ reconciled.records.set(todoId, withBaseline(record, row ? rowSnapshot(row, record) : { present: false }));
942
+ }
943
+ const localRefs = Object.fromEntries(localIds);
944
+ const existingTargets = todoEntities.map((entity) => ({ path: entity.relativePath, before: exactDiscoveredEntityBytes(entity), after: canonicalEntityEnvelopeBytes({ id: entity.id, artifact: "todo", record: reconciled.records.get(entity.id), migrationProvenance: entity.migrationProvenance ?? undefined }) }));
945
+ const newTargets = [...localIds.values()].map((id) => ({ path: relative(req.projectRoot, targetPath(req.projectRoot, sourceRoot, "todo", id)), before: null, after: "" }));
946
+ const targets = [...existingTargets, ...newTargets];
947
+ if (activating)
948
+ targets.push({ path: TODO_RECONCILIATION_ACTIVATION_PATH, before: null, after: activationBytesAfter });
949
+ targets.push({ path: publicRelative, before: publicExists ? markdownBefore : null, after: rendered });
950
+ const ordered = targets.filter((target) => target.before === null || !target.before.equals(Buffer.from(target.after))).sort((left, right) => Number(left.path === TODO_RECONCILIATION_ACTIVATION_PATH) - Number(right.path === TODO_RECONCILIATION_ACTIVATION_PATH) || Number(left.path === publicRelative) - Number(right.path === publicRelative) || left.path.localeCompare(right.path));
951
+ let effectSha256 = todoCreateBatchEffectSha256(req.input, todoBinding.mappingSha256, localRefs, ordered.filter((target) => !newTargets.includes(target)).map((target) => ({ path: target.path, before_sha256: target.before === null ? null : createHash("sha256").update(target.before).digest("hex"), after_sha256: createHash("sha256").update(target.after).digest("hex") })));
952
+ const batchReceipt = { effect_sha256: effectSha256, input_sha256: inputSha256, local_refs: localRefs };
953
+ for (const [localRef, id] of localIds) {
954
+ const record = reconciled.records.get(id);
955
+ record.reconciliation = { ...record.reconciliation, create_batch: batchReceipt };
956
+ newTargets.find((target) => target.path.endsWith(`/${id}.yaml`)).after = canonicalEntityEnvelopeBytes({ id, artifact: "todo", record });
957
+ }
958
+ effectSha256 = todoCreateBatchEffectSha256(req.input, todoBinding.mappingSha256, localRefs, ordered.filter((target) => !newTargets.includes(target)).map((target) => ({ path: target.path, before_sha256: target.before === null ? null : createHash("sha256").update(target.before).digest("hex"), after_sha256: createHash("sha256").update(target.after).digest("hex") })));
959
+ batchReceipt.effect_sha256 = effectSha256;
960
+ for (const [, id] of localIds) {
961
+ const record = reconciled.records.get(id);
962
+ record.reconciliation.create_batch = batchReceipt;
963
+ newTargets.find((target) => target.path.endsWith(`/${id}.yaml`)).after = canonicalEntityEnvelopeBytes({ id, artifact: "todo", record });
964
+ }
965
+ const response = { schemaVersion: "agentera.stateWrite.v1", command: "state todo create", status: "pass", artifact: "todo", records: createBatch.map(({ local_ref }) => ({ local_ref, id: localIds.get(local_ref), record: reconciled.records.get(localIds.get(local_ref)) })), local_refs: localRefs, operation: { verb: "create", dry_run: req.dryRun, idempotent_replay: false }, validation: { status: "pass", violations: [] }, effect_sha256: effectSha256, apply_command: req.dryRun ? `agentera state todo create --input <same-input> --effect-sha256 ${effectSha256} --yes --format json` : null };
966
+ if (req.dryRun)
967
+ return { ...response, reconciliation: { transaction_id: null, targets: targets.length, recovered } };
968
+ if (req.values.effect_sha256 !== effectSha256)
969
+ reject({ class: "conflict", message: "TODO create batch effects changed after preview", recovery: "Rerun the same batch input with --dry-run, review the new bounded effect, then use its exact effect SHA-256; no state was changed." });
970
+ const transaction = publishTodoReconciliation(context, sourceRoot, todoBinding, targets, { createBatch: batchReceipt, interruptAfterTarget: options.interruptAfterTarget, beforeCommit: () => { assertState(pinnedRoot, sourceRoot, sourceBinding); const currentBinding = todoReconciliationBinding(req.projectRoot, sourceRoot); if (currentBinding.publicPath !== todoBinding.publicPath || currentBinding.mappingSha256 !== todoBinding.mappingSha256)
971
+ reject({ class: "conflict", message: "TODO reconciliation mapping changed during create batch publication", recovery: "Preserve the changed docs mapping and retry after every transaction target is restored; no mapping bytes were overwritten." }); } });
972
+ context.assertValid();
973
+ return { ...response, reconciliation: { transaction_id: transaction.id, targets: transaction.targetCount, recovered } };
974
+ }
975
+ if (transitionBatch) {
976
+ const verb = transitionBatch.verb;
977
+ if (!req.dryRun && (!req.values.confirmed || !SHA256.test(String(req.values.effect_sha256 ?? ""))))
978
+ reject({ class: "invalid_request", message: `todo ${verb} batch apply requires its preview effect SHA-256 and --yes`, recovery: "Run the same batch input with --dry-run, review it, then repeat that input with the returned --effect-sha256 value and --yes; no state was changed." });
979
+ const inputSha256 = createHash("sha256").update(canonicalRecordJson(req.input)).digest("hex");
980
+ const priorReceipts = transitionBatch.entries.map(({ id }) => mapping(reconciled.records.get(id)?.reconciliation) ? reconciled.records.get(id).reconciliation.transition_batch : null);
981
+ if (priorReceipts.every((receipt) => mapping(receipt) && receipt.input_sha256 === inputSha256 && receipt.verb === verb && receipt.effect_sha256 === req.values.effect_sha256)) {
982
+ const divergent = transitionBatch.entries.find((entry, index) => !matchesTodoTransitionBatchPostState(reconciled.records.get(entry.id), priorReceipts[index], entry, verb));
983
+ if (divergent)
984
+ reject({ class: "conflict", message: `TODO ${verb} batch member '${divergent.id}' no longer matches its authorized post-state`, recovery: "Preserve the current singleton result and preview a fresh batch from current state; no state was changed." });
985
+ return { schemaVersion: "agentera.stateWrite.v1", command: `state todo ${verb}`, status: "pass", artifact: "todo", records: transitionBatch.entries.map(({ id }) => ({ id, record: reconciled.records.get(id) })), operation: { verb, dry_run: false, idempotent_replay: true }, validation: { status: "pass", violations: [] }, effect_sha256: req.values.effect_sha256, apply_command: null, reconciliation: { transaction_id: null, targets: 0, recovered } };
986
+ }
987
+ for (const { id } of transitionBatch.entries) {
988
+ selectedById(todoEntities, "todo", id);
989
+ if (verb === "resolve" && reconciled.records.get(id).status !== "open")
990
+ reject({ class: "conflict", message: "TODO resolve requires an open item", recovery: "Remove already-resolved items from the batch, then preview the complete open-item batch again; no state was changed." });
991
+ }
992
+ const requestedRecords = [];
993
+ for (const entry of transitionBatch.entries) {
994
+ const values = { id: entry.id, ...(entry.severity ? { severity: entry.severity } : {}), lifecycle: { reason: entry.reason, date: entry.date } };
995
+ const record = transitionRecord({ ...req, input: null, values, callerPayload: values }, reconciled.records.get(entry.id), todoEntities.map((entity) => ({ ...entity, record: reconciled.records.get(entity.id) })));
996
+ reconciled.records.set(entry.id, record);
997
+ requestedRecords.push({ id: entry.id, record });
998
+ }
999
+ for (const [todoId, record] of reconciled.records) {
1000
+ const violations = recordViolations("todo", record, sourceRoot);
1001
+ if (violations.length)
1002
+ reject({ class: "schema_violation", message: `todo ${verb} batch input is invalid`, violations, recovery: "Correct the complete batch and preview it again; no state was changed." });
1003
+ }
1004
+ const visibleRecords = new Map([...reconciled.records].filter(([todoId]) => reconciled.visible.has(todoId)));
1005
+ const rendered = renderManagedMarkdown(markdown, visibleRecords, rows);
1006
+ const activationBytesAfter = activation ? loadedActivation.bytes.toString("utf8") : todoReconciliationActivationBytes(scan.retainedLegacyRows);
1007
+ const activationAfter = activation ?? JSON.parse(activationBytesAfter);
1008
+ const finalRows = managedRows(rendered, activationAfter, todoEntities).rows;
1009
+ for (const [todoId, record] of reconciled.records) {
1010
+ const row = finalRows.get(todoId);
1011
+ reconciled.records.set(todoId, withBaseline(record, row ? rowSnapshot(row, record) : { present: false }));
1012
+ }
1013
+ for (const { id } of transitionBatch.entries) {
1014
+ const reconciliation = reconciled.records.get(id).reconciliation;
1015
+ delete reconciliation.transition_batch;
1016
+ }
1017
+ const targets = todoEntities.map((entity) => ({ path: entity.relativePath, before: exactDiscoveredEntityBytes(entity), after: canonicalEntityEnvelopeBytes({ id: entity.id, artifact: "todo", record: reconciled.records.get(entity.id), migrationProvenance: entity.migrationProvenance ?? undefined }) }));
1018
+ targets.push({ path: publicRelative, before: publicExists ? markdownBefore : null, after: rendered });
1019
+ const effectTargets = targets.filter((target) => target.before === null || !target.before.equals(Buffer.from(target.after))).sort((left, right) => Number(left.path === publicRelative) - Number(right.path === publicRelative) || left.path.localeCompare(right.path));
1020
+ const effectSha256 = todoUpdateBatchEffectSha256(req.input, todoBinding.mappingSha256, effectTargets.map((target) => ({ path: target.path, before_sha256: target.before === null ? null : createHash("sha256").update(target.before).digest("hex"), after_sha256: createHash("sha256").update(target.after).digest("hex") })));
1021
+ const response = { schemaVersion: "agentera.stateWrite.v1", command: `state todo ${verb}`, status: "pass", artifact: "todo", records: requestedRecords.map(({ id }) => ({ id, record: reconciled.records.get(id) })), operation: { verb, dry_run: req.dryRun, idempotent_replay: false }, validation: { status: "pass", violations: [] }, effect_sha256: effectSha256, apply_command: req.dryRun ? `agentera state todo ${verb} --input <same-input> --effect-sha256 ${effectSha256} --yes --format json` : null };
1022
+ if (req.dryRun)
1023
+ return { ...response, reconciliation: { transaction_id: null, targets: targets.length, recovered } };
1024
+ if (req.values.effect_sha256 !== effectSha256)
1025
+ reject({ class: "conflict", message: `TODO ${verb} batch effects changed after preview`, recovery: "Rerun the same batch input with --dry-run, review the new bounded effect, then use its exact effect SHA-256; no state was changed." });
1026
+ for (const { id } of transitionBatch.entries) {
1027
+ const record = reconciled.records.get(id);
1028
+ record.reconciliation = { ...record.reconciliation, transition_batch: { verb, input_sha256: inputSha256, effect_sha256: effectSha256, post_state_sha256: todoTransitionBatchPostStateSha256(record) } };
1029
+ const target = targets.find(({ path }) => path.endsWith(`/${id}.yaml`));
1030
+ target.after = canonicalEntityEnvelopeBytes({ id, artifact: "todo", record });
1031
+ }
1032
+ const transaction = publishTodoReconciliation(context, sourceRoot, todoBinding, targets, { updateBatchEffectSha256: effectSha256, interruptAfterTarget: options.interruptAfterTarget, beforeCommit: () => { assertState(pinnedRoot, sourceRoot, sourceBinding); const currentBinding = todoReconciliationBinding(req.projectRoot, sourceRoot); if (currentBinding.publicPath !== todoBinding.publicPath || currentBinding.mappingSha256 !== todoBinding.mappingSha256)
1033
+ reject({ class: "conflict", message: `TODO reconciliation mapping changed during ${verb} batch publication`, recovery: "Preserve the changed docs mapping and retry after every transaction target is restored; no mapping bytes were overwritten." }); } });
1034
+ context.assertValid();
1035
+ return { ...response, records: transitionBatch.entries.map(({ id }) => ({ id, record: reconciled.records.get(id) })), reconciliation: { transaction_id: transaction.id, targets: transaction.targetCount, recovered } };
1036
+ }
1037
+ if (batch) {
1038
+ if (!req.dryRun && (!req.values.confirmed || !SHA256.test(String(req.values.effect_sha256 ?? ""))))
1039
+ reject({ class: "invalid_request", message: "todo update batch apply requires its preview effect SHA-256 and --yes", recovery: "Run the same batch input with --dry-run, review it, then repeat that input with the returned --effect-sha256 value and --yes; no state was changed." });
1040
+ const requestedRecords = [];
1041
+ for (const { id, patch } of batch) {
1042
+ selectedById(todoEntities, "todo", id);
1043
+ const record = mutationRecord({ ...req, input: patch, values: { id }, callerPayload: patch }, reconciled.records.get(id), todoEntities.map((entity) => ({ ...entity, record: reconciled.records.get(entity.id) })));
1044
+ reconciled.records.set(id, record);
1045
+ requestedRecords.push({ id, record });
1046
+ }
1047
+ const referenceEntities = [...reconciled.records].map(([todoId, record]) => ({ boundary: TODO.boundary, id: todoId, record }));
1048
+ for (const [todoId, record] of reconciled.records) {
1049
+ const violations = recordViolations("todo", record, sourceRoot);
1050
+ if (violations.length)
1051
+ reject({ class: "schema_violation", message: "todo entity input is invalid", violations, recovery: "Correct the batch patch and preview the complete batch again; no state was changed." });
1052
+ if (record.readiness !== undefined)
1053
+ assertTodoReferences(todoId, record, referenceEntities);
1054
+ }
1055
+ const visibleRecords = new Map([...reconciled.records].filter(([todoId]) => reconciled.visible.has(todoId)));
1056
+ const rendered = renderManagedMarkdown(markdown, visibleRecords, rows);
1057
+ const activationBytesAfter = activation ? loadedActivation.bytes.toString("utf8") : todoReconciliationActivationBytes(scan.retainedLegacyRows);
1058
+ const activationAfter = activation ?? JSON.parse(activationBytesAfter);
1059
+ const finalRows = managedRows(rendered, activationAfter, todoEntities).rows;
1060
+ for (const [todoId, record] of reconciled.records) {
1061
+ const row = finalRows.get(todoId);
1062
+ reconciled.records.set(todoId, withBaseline(record, row ? rowSnapshot(row, record) : { present: false }));
1063
+ }
1064
+ const targets = todoEntities.map((entity) => ({ path: entity.relativePath, before: exactDiscoveredEntityBytes(entity), after: canonicalEntityEnvelopeBytes({ id: entity.id, artifact: "todo", record: reconciled.records.get(entity.id), migrationProvenance: entity.migrationProvenance ?? undefined }) }));
1065
+ if (activating)
1066
+ targets.push({ path: TODO_RECONCILIATION_ACTIVATION_PATH, before: null, after: activationBytesAfter });
1067
+ targets.push({ path: publicRelative, before: publicExists ? markdownBefore : null, after: rendered });
1068
+ const effectTargets = targets.filter((target) => target.before === null || !target.before.equals(Buffer.from(target.after))).sort((left, right) => Number(left.path === publicRelative) - Number(right.path === publicRelative) || left.path.localeCompare(right.path));
1069
+ const effectSha256 = todoUpdateBatchEffectSha256(req.input, todoBinding.mappingSha256, effectTargets.map((target) => ({ path: target.path, before_sha256: target.before === null ? null : createHash("sha256").update(target.before).digest("hex"), after_sha256: createHash("sha256").update(target.after).digest("hex") })));
1070
+ const applyCommand = `agentera state todo update --input <same-input> --effect-sha256 ${effectSha256} --yes --format json`;
1071
+ const response = { schemaVersion: "agentera.stateWrite.v1", command: "state todo update", status: "pass", artifact: "todo", records: requestedRecords.map(({ id }) => ({ id, record: reconciled.records.get(id) })), operation: { verb: "update", dry_run: req.dryRun, idempotent_replay: false }, validation: { status: "pass", violations: [] }, effect_sha256: effectSha256, apply_command: req.dryRun ? applyCommand : null };
1072
+ if (req.dryRun)
1073
+ return { ...response, reconciliation: { transaction_id: null, targets: targets.length, recovered } };
1074
+ if (req.values.effect_sha256 !== effectSha256)
1075
+ reject({ class: "conflict", message: "TODO update batch effects changed after preview", recovery: "Rerun the same batch input with --dry-run, review the new bounded effect, then use its exact effect SHA-256; no state was changed." });
1076
+ const transaction = publishTodoReconciliation(context, sourceRoot, todoBinding, targets, { updateBatchEffectSha256: effectSha256, interruptAfterTarget: options.interruptAfterTarget, beforeCommit: () => { assertState(pinnedRoot, sourceRoot, sourceBinding); const currentBinding = todoReconciliationBinding(req.projectRoot, sourceRoot); if (currentBinding.publicPath !== todoBinding.publicPath || currentBinding.mappingSha256 !== todoBinding.mappingSha256)
1077
+ reject({ class: "conflict", message: "TODO reconciliation mapping changed during batch publication", recovery: "Preserve the changed docs mapping and retry after every transaction target is restored; no mapping bytes were overwritten." }); } });
1078
+ context.assertValid();
1079
+ return { ...response, reconciliation: { transaction_id: transaction.id, targets: transaction.targetCount, recovered } };
1080
+ }
871
1081
  let id;
872
1082
  let requested;
873
1083
  let selected;
@@ -3,10 +3,12 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { loadYamlMapping } from "../core/yaml.js";
5
5
  import { canonicalRecordJson } from "./archiveDiscovery.js";
6
- import { entityExactGetMaxBytes } from "./entityStorage.js";
6
+ import { canonicalEntityEnvelopeBytes, entityExactGetMaxBytes } from "./entityStorage.js";
7
7
  import { FILE_REPLACEMENT_METADATA_NAME, FILE_REPLACEMENT_RECOVERY_VERSION, validateEntityRecoveryDirectory, } from "./entityPublicationContext.js";
8
8
  import { ExactReplacementConflictError, FileReplacementError } from "./exactReplacementRecovery.js";
9
9
  import { TODO_RECONCILIATION_ACTIVATION_PATH } from "./todoReconciliationActivation.js";
10
+ import { todoUpdateBatchEffectSha256 } from "./todoUpdateBatch.js";
11
+ import { todoCreateBatchEffectSha256 } from "./todoCreateBatch.js";
10
12
  import { reject } from "./write/errors.js";
11
13
  const VERSION = "agentera.todoReconciliationTransaction.v1";
12
14
  const DIRECTORY = ".agentera/.todo-reconciliation";
@@ -62,7 +64,7 @@ function createReceiptFromTargets(targets) {
62
64
  if (!created.length)
63
65
  return undefined;
64
66
  if (created.length !== 1)
65
- invalidJournal("TODO reconciliation journal has multiple immutable TODO entity targets");
67
+ return undefined;
66
68
  const target = created[0];
67
69
  const createdId = path.posix.basename(target.path, ".yaml");
68
70
  let envelope;
@@ -99,7 +101,7 @@ function parseJournal(bytes, fileName) {
99
101
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
100
102
  invalidJournal("TODO reconciliation journal is not a mapping");
101
103
  const value = parsed;
102
- const expectedKeys = ["schema_version", "id", "public_path", "mapping_sha256", "targets", ...(value.create === undefined ? [] : ["create"]), ...(value.activation_effect_sha256 === undefined ? [] : ["activation_effect_sha256"]), ...(value.owner_mapping_sha256 === undefined ? [] : ["owner_mapping_sha256"])].sort().join(",");
104
+ const expectedKeys = ["schema_version", "id", "public_path", "mapping_sha256", "targets", ...(value.create === undefined ? [] : ["create"]), ...(value.create_batch === undefined ? [] : ["create_batch"]), ...(value.activation_effect_sha256 === undefined ? [] : ["activation_effect_sha256"]), ...(value.owner_mapping_sha256 === undefined ? [] : ["owner_mapping_sha256"]), ...(value.update_batch_effect_sha256 === undefined ? [] : ["update_batch_effect_sha256"])].sort().join(",");
103
105
  if (Object.keys(value).sort().join(",") !== expectedKeys
104
106
  || value.schema_version !== VERSION
105
107
  || typeof value.id !== "string"
@@ -115,6 +117,10 @@ function parseJournal(bytes, fileName) {
115
117
  invalidJournal("TODO reconciliation journal has an invalid activation effect authorization");
116
118
  if (value.owner_mapping_sha256 !== undefined && !/^[a-f0-9]{64}$/.test(value.owner_mapping_sha256))
117
119
  invalidJournal("TODO reconciliation journal has an invalid owner-mapping authorization");
120
+ if (value.update_batch_effect_sha256 !== undefined && !/^[a-f0-9]{64}$/.test(value.update_batch_effect_sha256))
121
+ invalidJournal("TODO reconciliation journal has an invalid update-batch effect authorization");
122
+ if (value.create_batch !== undefined && (!value.create_batch || typeof value.create_batch !== "object" || Array.isArray(value.create_batch) || Object.keys(value.create_batch).sort().join(",") !== "effect_sha256,input_sha256,local_refs" || !/^[a-f0-9]{64}$/.test(value.create_batch.effect_sha256) || !/^[a-f0-9]{64}$/.test(value.create_batch.input_sha256) || !value.create_batch.local_refs || typeof value.create_batch.local_refs !== "object" || Array.isArray(value.create_batch.local_refs) || Object.entries(value.create_batch.local_refs).some(([ref, id]) => !/^[a-z][a-z0-9_-]{0,63}$/.test(ref) || typeof id !== "string" || !/^[a-z]{10}$/.test(id))))
123
+ invalidJournal("TODO reconciliation journal has an invalid create-batch receipt");
118
124
  if (value.create !== undefined && (!value.create
119
125
  || typeof value.create !== "object"
120
126
  || Array.isArray(value.create)
@@ -151,14 +157,14 @@ function parseJournal(bytes, fileName) {
151
157
  || value.create.created_id !== inferredCreate.created_id
152
158
  || value.create.request_sha256 !== inferredCreate.request_sha256))
153
159
  invalidJournal("TODO reconciliation create receipt does not match its canonical entity target");
154
- const identity = value.create || value.activation_effect_sha256 || value.owner_mapping_sha256
155
- ? { ...(value.create ? { create: value.create } : {}), ...(value.activation_effect_sha256 ? { activation_effect_sha256: value.activation_effect_sha256 } : {}), ...(value.owner_mapping_sha256 ? { owner_mapping_sha256: value.owner_mapping_sha256 } : {}), targets: body }
160
+ const identity = value.create || value.create_batch || value.activation_effect_sha256 || value.owner_mapping_sha256 || value.update_batch_effect_sha256
161
+ ? { ...(value.create ? { create: value.create } : {}), ...(value.create_batch ? { create_batch: value.create_batch } : {}), ...(value.activation_effect_sha256 ? { activation_effect_sha256: value.activation_effect_sha256 } : {}), ...(value.owner_mapping_sha256 ? { owner_mapping_sha256: value.owner_mapping_sha256 } : {}), ...(value.update_batch_effect_sha256 ? { update_batch_effect_sha256: value.update_batch_effect_sha256 } : {}), targets: body }
156
162
  : body;
157
163
  const expectedId = createHash("sha256").update(canonicalRecordJson(identity)).digest("hex").slice(0, 24);
158
164
  if (value.id !== expectedId || (fileName !== undefined && fileName !== `${value.id}.json`)) {
159
165
  invalidJournal("TODO reconciliation journal identity does not match its canonical targets");
160
166
  }
161
- const create = value.create ?? inferredCreate;
167
+ const create = value.create ?? (value.create_batch ? undefined : inferredCreate);
162
168
  return { ...value, ...(create ? { create } : {}) };
163
169
  }
164
170
  function targetLimit(target, sourceRoot) {
@@ -446,6 +452,29 @@ export function recoverTodoReconciliation(context, sourceRoot, binding, options
446
452
  const journalBytes = fs.readFileSync(path.join(root, relative));
447
453
  const journal = parseJournal(journalBytes, name);
448
454
  assertBinding(journal, binding);
455
+ if (journal.update_batch_effect_sha256) {
456
+ const supplied = options.updateBatch;
457
+ const transition = supplied && /^agentera\.todo(?:SetSeverity|Resolve)Batch\.v1$/.test(String(supplied.input.schema_version));
458
+ const requestEffectSha256 = supplied ? todoUpdateBatchEffectSha256(supplied.input, journal.mapping_sha256, journal.targets.map((target) => ({
459
+ path: target.path,
460
+ before_sha256: target.before === null ? null : createHash("sha256").update(decode(target.before)).digest("hex"),
461
+ after_sha256: createHash("sha256").update(transition && target.path.includes("/todo_item/") ? (() => { const value = loadYamlMapping(decode(target.after).toString("utf8")); const record = value.record; if (record.reconciliation && typeof record.reconciliation === "object" && !Array.isArray(record.reconciliation))
462
+ delete record.reconciliation.transition_batch; return canonicalEntityEnvelopeBytes({ id: String(value.id), artifact: "todo", record }); })() : decode(target.after)).digest("hex"),
463
+ }))) : null;
464
+ if (!supplied || supplied.effectSha256 !== journal.update_batch_effect_sha256 || requestEffectSha256 !== journal.update_batch_effect_sha256)
465
+ reject({
466
+ class: "conflict",
467
+ message: "pending TODO update batch does not match this request and effect authorization",
468
+ recovery: "Retry the exact original TODO update batch input with its preview effect SHA-256 and --yes; no transaction target bytes were changed.",
469
+ });
470
+ }
471
+ if (journal.create_batch) {
472
+ const supplied = options.createBatch;
473
+ const requestInputSha256 = supplied ? createHash("sha256").update(canonicalRecordJson(supplied.input)).digest("hex") : null;
474
+ const requestEffectSha256 = supplied ? todoCreateBatchEffectSha256(supplied.input, journal.mapping_sha256, journal.create_batch.local_refs, journal.targets.filter((target) => !(target.before === null && /^\.agentera\/entities\/todo\/todo_item\/[a-z]{10}\.yaml$/.test(target.path))).map((target) => ({ path: target.path, before_sha256: target.before === null ? null : createHash("sha256").update(decode(target.before)).digest("hex"), after_sha256: createHash("sha256").update(decode(target.after)).digest("hex") }))) : null;
475
+ if (!supplied || supplied.effectSha256 !== journal.create_batch.effect_sha256 || requestInputSha256 !== journal.create_batch.input_sha256 || requestEffectSha256 !== journal.create_batch.effect_sha256)
476
+ reject({ class: "conflict", message: "pending TODO create batch does not match this request and effect authorization", recovery: "Retry the exact original TODO create batch input with its preview effect SHA-256 and --yes; no transaction target bytes were changed." });
477
+ }
449
478
  if (journal.create && options.createRequestSha256 !== journal.create.request_sha256)
450
479
  reject({
451
480
  class: "conflict",
@@ -486,6 +515,8 @@ export function recoverTodoReconciliation(context, sourceRoot, binding, options
486
515
  transaction_id: journal.id,
487
516
  target_count: journal.targets.length,
488
517
  ...(journal.create ? { create: journal.create } : {}),
518
+ ...(journal.update_batch_effect_sha256 ? { update_batch_effect_sha256: journal.update_batch_effect_sha256 } : {}),
519
+ ...(journal.create_batch ? { create_batch: journal.create_batch } : {}),
489
520
  });
490
521
  }
491
522
  catch (error) {
@@ -512,8 +543,8 @@ export function publishTodoReconciliation(context, sourceRoot, binding, targets,
512
543
  reject({ class: "schema_violation", message: "TODO activation authorization requires its activation target", recovery: "Recompute the complete activation target set from a fresh dry-run; no state was changed." });
513
544
  if (options.ownerMappingSha256 && !options.activationEffectSha256)
514
545
  reject({ class: "schema_violation", message: "TODO owner-mapping authorization requires its effect authorization", recovery: "Recompute the complete owner correction target set from a fresh dry-run; no state was changed." });
515
- const identity = options.create || options.activationEffectSha256 || options.ownerMappingSha256
516
- ? { ...(options.create ? { create: options.create } : {}), ...(options.activationEffectSha256 ? { activation_effect_sha256: options.activationEffectSha256 } : {}), ...(options.ownerMappingSha256 ? { owner_mapping_sha256: options.ownerMappingSha256 } : {}), targets: body }
546
+ const identity = options.create || options.createBatch || options.activationEffectSha256 || options.ownerMappingSha256 || options.updateBatchEffectSha256
547
+ ? { ...(options.create ? { create: options.create } : {}), ...(options.createBatch ? { create_batch: options.createBatch } : {}), ...(options.activationEffectSha256 ? { activation_effect_sha256: options.activationEffectSha256 } : {}), ...(options.ownerMappingSha256 ? { owner_mapping_sha256: options.ownerMappingSha256 } : {}), ...(options.updateBatchEffectSha256 ? { update_batch_effect_sha256: options.updateBatchEffectSha256 } : {}), targets: body }
517
548
  : body;
518
549
  const id = createHash("sha256").update(canonicalRecordJson(identity)).digest("hex").slice(0, 24);
519
550
  if (!normalized.length)
@@ -544,8 +575,10 @@ export function publishTodoReconciliation(context, sourceRoot, binding, targets,
544
575
  public_path: binding.publicPath,
545
576
  mapping_sha256: binding.mappingSha256,
546
577
  ...(options.create ? { create: options.create } : {}),
578
+ ...(options.createBatch ? { create_batch: options.createBatch } : {}),
547
579
  ...(options.activationEffectSha256 ? { activation_effect_sha256: options.activationEffectSha256 } : {}),
548
580
  ...(options.ownerMappingSha256 ? { owner_mapping_sha256: options.ownerMappingSha256 } : {}),
581
+ ...(options.updateBatchEffectSha256 ? { update_batch_effect_sha256: options.updateBatchEffectSha256 } : {}),
549
582
  targets: body,
550
583
  };
551
584
  const bytes = `${JSON.stringify(journal)}\n`;
@@ -0,0 +1,65 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonicalRecordJson } from "./archiveDiscovery.js";
3
+ import { reject } from "./write/errors.js";
4
+ const ID = /^[a-z]{10}$/;
5
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
6
+ const SEVERITIES = new Set(["critical", "degraded", "normal", "annoying"]);
7
+ export function todoTransitionBatchPostStateSha256(record) {
8
+ const value = structuredClone(record);
9
+ if (value.reconciliation && typeof value.reconciliation === "object" && !Array.isArray(value.reconciliation))
10
+ delete value.reconciliation.transition_batch;
11
+ return createHash("sha256").update(canonicalRecordJson(value)).digest("hex");
12
+ }
13
+ export function matchesTodoTransitionBatchPostState(record, receipt, entry, verb) {
14
+ const lifecycle = record.lifecycle && typeof record.lifecycle === "object" && !Array.isArray(record.lifecycle) ? record.lifecycle : null;
15
+ return receipt.post_state_sha256 === todoTransitionBatchPostStateSha256(record)
16
+ && lifecycle !== null
17
+ && canonicalRecordJson(lifecycle) === canonicalRecordJson({ operation: verb, reason: entry.reason, date: entry.date })
18
+ && (verb === "set-severity" ? record.severity === entry.severity : record.status === "resolved");
19
+ }
20
+ export function parseTodoTransitionBatch(input, verb) {
21
+ if (verb !== "set-severity" && verb !== "resolve")
22
+ return null;
23
+ const version = `agentera.todo${verb === "set-severity" ? "SetSeverity" : "Resolve"}Batch.v1`;
24
+ if (!input || input.schema_version !== version)
25
+ return null;
26
+ const member = verb === "set-severity" ? "transitions" : "resolutions";
27
+ const values = input[member];
28
+ const violations = [];
29
+ if (Object.keys(input).sort().join(",") !== ["schema_version", member].sort().join(",") || !Array.isArray(values))
30
+ violations.push(`batch envelope must contain exactly schema_version and ${member}`);
31
+ if (!Array.isArray(values) || values.length < 1 || values.length > 256)
32
+ violations.push(`${member} must contain 1 to 256 entries`);
33
+ const expected = verb === "set-severity" ? "date,id,reason,severity" : "date,id,reason";
34
+ const entries = (Array.isArray(values) ? values : []).map((value, index) => {
35
+ const item = value && typeof value === "object" && !Array.isArray(value) ? value : {};
36
+ if (Object.keys(item).sort().join(",") !== expected)
37
+ violations.push(`${member}[${index}] must contain exactly ${expected.split(",").join(", ")}`);
38
+ const id = String(item.id ?? "");
39
+ const reason = String(item.reason ?? "").trim();
40
+ const date = String(item.date ?? "");
41
+ const severity = item.severity === undefined ? undefined : String(item.severity);
42
+ if (!ID.test(id))
43
+ violations.push(`${member}[${index}].id must be a bare ten-letter TODO ID`);
44
+ if (!reason || [...reason].length > 500)
45
+ violations.push(`${member}[${index}].reason must contain 1 to 500 code points`);
46
+ const timestamp = Date.parse(`${date}T00:00:00Z`);
47
+ if (!DATE.test(date) || Number.isNaN(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== date)
48
+ violations.push(`${member}[${index}].date must be a valid YYYY-MM-DD date`);
49
+ if (verb === "set-severity" && !SEVERITIES.has(severity ?? ""))
50
+ violations.push(`${member}[${index}].severity is invalid`);
51
+ return { id, reason, date, ...(severity === undefined ? {} : { severity }) };
52
+ });
53
+ const seen = new Set();
54
+ for (const { id } of entries) {
55
+ if (id && seen.has(id))
56
+ violations.push(`duplicate ${verb} target '${id}'`);
57
+ seen.add(id);
58
+ }
59
+ if (violations.length)
60
+ reject({ class: "schema_violation", message: `todo ${verb} batch input is invalid`, violations, recovery: `Correct the strict ${version} envelope, then preview it again; no state was changed.` });
61
+ return { verb, entries };
62
+ }
63
+ export function todoTransitionBatchEffectSha256(input, mappingSha256, targets) {
64
+ return createHash("sha256").update(canonicalRecordJson({ input, mapping_sha256: mappingSha256, targets })).digest("hex");
65
+ }
@@ -0,0 +1,50 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonicalRecordJson } from "./archiveDiscovery.js";
3
+ import { todoInputViolations } from "./todoDocsEntityValidation.js";
4
+ import { reject } from "./write/errors.js";
5
+ export const TODO_UPDATE_BATCH_VERSION = "agentera.todoUpdateBatch.v1";
6
+ const ID = /^[a-z]{10}$/;
7
+ function mapping(value) {
8
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9
+ }
10
+ export function inspectTodoUpdateBatch(input) {
11
+ if (!input || input.schema_version !== TODO_UPDATE_BATCH_VERSION)
12
+ return null;
13
+ const strictEnvelope = Object.keys(input).sort().join(",") === "schema_version,updates" && Array.isArray(input.updates);
14
+ const violations = [];
15
+ if (Object.keys(input).sort().join(",") !== "schema_version,updates")
16
+ violations.push("batch envelope must contain exactly schema_version and updates");
17
+ if (!Array.isArray(input.updates) || input.updates.length < 1 || input.updates.length > 256)
18
+ violations.push("updates must contain 1 to 256 entries");
19
+ const updates = (Array.isArray(input.updates) ? input.updates : []).map((value, index) => {
20
+ if (!mapping(value) || Object.keys(value).sort().join(",") !== "id,patch" || !ID.test(String(value.id ?? "")) || !mapping(value.patch)) {
21
+ violations.push(`updates[${index}] must contain exactly one bare id and one patch mapping`);
22
+ return { id: "", patch: {} };
23
+ }
24
+ violations.push(...todoInputViolations(value.patch, "update").map((item) => `updates[${index}].patch: ${item}`));
25
+ return { id: String(value.id), patch: value.patch };
26
+ });
27
+ const seen = new Set();
28
+ for (const { id } of updates) {
29
+ if (id && seen.has(id))
30
+ violations.push(`duplicate update target '${id}'`);
31
+ seen.add(id);
32
+ }
33
+ return { strictEnvelope, updates, violations };
34
+ }
35
+ export function parseTodoUpdateBatch(input) {
36
+ const inspected = inspectTodoUpdateBatch(input);
37
+ if (!inspected)
38
+ return null;
39
+ if (inspected.violations.length)
40
+ reject({ class: "schema_violation", message: "todo update batch input is invalid", violations: inspected.violations, recovery: "Correct the strict agentera.todoUpdateBatch.v1 envelope, then preview it again; no state was changed." });
41
+ return inspected.updates;
42
+ }
43
+ export function todoUpdateBatchEffectSha256(input, mappingSha256, targets) {
44
+ return createHash("sha256").update(canonicalRecordJson({
45
+ schema_version: TODO_UPDATE_BATCH_VERSION,
46
+ input,
47
+ mapping_sha256: mappingSha256,
48
+ targets,
49
+ })).digest("hex");
50
+ }
@@ -300,14 +300,17 @@ function decisionsGuidance(artifact, verb, _entityHealth = false, entityArtifact
300
300
  return ["a bare ten-letter objective ID is assigned by the CLI; do not pass an identity", ...base];
301
301
  if (entityArtifact && artifact === "todo" && verb === "update")
302
302
  return [
303
- "select one TODO item with its bare ten-letter --id; numeric, prefixed, composite, alias, and path identities are unavailable",
304
- "the input document is a patch: omitted fields preserve state and only target_version, requirements, acceptance, and readiness accept typed clears",
303
+ "for a singleton, select one TODO item with its bare ten-letter --id; numeric, prefixed, composite, alias, and path identities are unavailable",
304
+ "for a singleton, the input document is a patch: omitted fields preserve state and only target_version, requirements, acceptance, and readiness accept typed clears",
305
+ "for a batch, omit --id and supply one strict agentera.todoUpdateBatch.v1 envelope; preview it with --dry-run before exact confirmed apply",
305
306
  "public fields are TODO.md-owned; readiness, dependencies, gates, and evidence are Agentera-owned",
306
307
  ...base,
307
308
  ];
308
309
  if (entityArtifact && artifact === "todo" && ["set-severity", "supersede", "resolve", "reopen"].includes(verb))
309
310
  return [
310
- "select one TODO item with its bare ten-letter --id; lifecycle transitions are flag-only and accept no --input record",
311
+ ["set-severity", "resolve"].includes(verb)
312
+ ? `for a singleton, select one TODO item with its bare ten-letter --id and use only flags; for a batch, omit --id and supply the strict ${verb === "set-severity" ? "agentera.todoSetSeverityBatch.v1" : "agentera.todoResolveBatch.v1"} envelope through --input`
313
+ : "select one TODO item with its bare ten-letter --id; lifecycle transitions are flag-only and accept no --input record",
311
314
  "supply a reason and YYYY-MM-DD date; supersede additionally requires a distinct existing replacement ID",
312
315
  ...base,
313
316
  ];
@@ -51,11 +51,11 @@ const RUNTIME_OPERATION_CORES = [
51
51
  op("todo", "activate", [f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { ownedFields: ["reconciliation", "public_document", "activation"], compacts: true }),
52
52
  op("todo", "repair", [f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { ownedFields: ["reconciliation", "public_document", "activation"], compacts: true }),
53
53
  op("todo", "correct-owners", [f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { ownedFields: ["reconciliation", "public_document", "activation"], inputMode: "structured", inputRoot: "one unsafe TODO owner mapping", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], inputMaxBytes: 32768, compacts: true }),
54
- op("todo", "create", [], { ownedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMode: "structured", inputRoot: "full typed TODO record", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMaxBytes: 32768 }),
55
- op("todo", "update", [f("--id", "id", "string", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMode: "structured", inputRoot: "TODO record patch", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMaxBytes: 32768 }),
56
- op("todo", "set-severity", [f("--id", "id", "string", { required: true }), f("--severity", "severity", "string", { required: true, validValues: ["critical", "degraded", "normal", "annoying"] }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact", "severity", "lifecycle"] }),
54
+ op("todo", "create", [f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { ownedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMode: "structured", inputRoot: "full typed TODO record or agentera.todoCreateBatch.v1 envelope", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMaxBytes: 32768 }),
55
+ op("todo", "update", [f("--id", "id", "string", { required: true }), f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMode: "structured", inputRoot: "TODO record patch or agentera.todoUpdateBatch.v1 envelope", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMaxBytes: 32768 }),
56
+ op("todo", "set-severity", [f("--id", "id", "string", { required: true }), f("--severity", "severity", "string", { required: true, validValues: ["critical", "degraded", "normal", "annoying"] }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true }), f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { selectors: ["--id"], ownedFields: ["id", "artifact", "severity", "lifecycle"], inputMode: "structured", inputRoot: "agentera.todoSetSeverityBatch.v1 envelope", inputOptional: true, inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], inputMaxBytes: 32768 }),
57
57
  op("todo", "supersede", [f("--id", "id", "string", { required: true }), f("--replacement", "lifecycle.replacement", "string", { required: true }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "lifecycle"] }),
58
- op("todo", "resolve", [f("--id", "id", "string", { required: true }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "lifecycle"] }),
58
+ op("todo", "resolve", [f("--id", "id", "string", { required: true }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true }), f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "lifecycle"], inputMode: "structured", inputRoot: "agentera.todoResolveBatch.v1 envelope", inputOptional: true, inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], inputMaxBytes: 32768 }),
59
59
  op("todo", "reopen", [f("--id", "id", "string", { required: true }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "lifecycle"] }),
60
60
  op("docs", "create", [], { ownedFields: ["id", "artifact"], inputMode: "structured", inputRoot: "one documentation inventory entry", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact"], inputMaxBytes: 32768 }),
61
61
  op("docs", "update", [f("--id", "id", "string", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact"], inputMode: "structured", inputRoot: "one documentation inventory entry", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact"], inputMaxBytes: 32768 }),
@@ -99,11 +99,11 @@ const RUNTIME_OPERATION_PROJECTIONS = {
99
99
  "todo.activate": projection("Preview and review every reported safe activation effect before explicit confirmed apply; unsafe inactive evidence requires the separate effect-bound owner-correction operation.", developmentCommand("state todo activate --dry-run --format json"), developmentCommand("state todo activate --effect-sha256 EFFECT_SHA256 --yes --format json")),
100
100
  "todo.repair": projection("Preview and review every diagnosed repair decision before explicit confirmed apply; ambiguous evidence is rejected without effects.", developmentCommand("state todo repair --dry-run --format json"), developmentCommand("state todo repair --effect-sha256 EFFECT_SHA256 --yes --format json")),
101
101
  "todo.correct-owners": projection("Supply one complete id/source_line owner mapping, preview its bounded effect, then apply only the exact returned effect; malformed, ambiguous, unmatched, or stale evidence is rejected without effects.", developmentCommand("state todo correct-owners --input owner-mapping.yaml --dry-run --format json"), developmentCommand("state todo correct-owners --input owner-mapping.yaml --effect-sha256 EFFECT_SHA256 --yes --format json")),
102
- "todo.create": projection(`Run ${developmentCommand("state todo explain --verb create --format json")}, remove CLI-owned fields, provide the full typed TODO record, and retry.`, developmentCommand("state todo create --input todo.yaml --format json")),
103
- "todo.update": projection("Reread the TODO item, use its bare ID, supply only typed patch fields, and use null or an empty list only for declared clearable fields.", developmentCommand("state todo update --id qjtrmnpvka --input todo-patch.yaml --format json")),
104
- "todo.set-severity": projection("Use the bare TODO ID, one immediate-impact severity, a reason, and a YYYY-MM-DD date; no record input is accepted.", developmentCommand('state todo set-severity --id qjtrmnpvka --severity degraded --reason "Impact changed" --date 2026-07-31 --format json')),
102
+ "todo.create": projection("For one item, preserve the existing full-record form. For a batch, supply agentera.todoCreateBatch.v1, preview with --dry-run, then repeat the same input with its effect SHA-256 and --yes.", developmentCommand("state todo create --input todo.yaml --format json")),
103
+ "todo.update": projection("For one item, preserve the existing --id and patch form. For a batch, supply agentera.todoUpdateBatch.v1, preview with --dry-run, then repeat the same input with its effect SHA-256 and --yes.", developmentCommand("state todo update --id qjtrmnpvka --input todo-patch.yaml --format json")),
104
+ "todo.set-severity": projection("Use singleton flags unchanged, or supply agentera.todoSetSeverityBatch.v1 through --input, preview it, then exact-apply it with the effect SHA-256 and --yes.", developmentCommand('state todo set-severity --id qjtrmnpvka --severity degraded --reason "Impact changed" --date 2026-07-31 --format json')),
105
105
  "todo.supersede": projection("Use the selected bare TODO ID, an existing distinct replacement ID, a reason, and a YYYY-MM-DD date; no record input is accepted.", developmentCommand('state todo supersede --id qjtrmnpvka --replacement zqtrmnpvka --reason "Replaced by narrower work" --date 2026-07-31 --format json')),
106
- "todo.resolve": projection("Use the bare TODO ID, a reason, and a YYYY-MM-DD date; no record input is accepted.", developmentCommand('state todo resolve --id qjtrmnpvka --reason "Shipped" --date 2026-07-31 --format json')),
106
+ "todo.resolve": projection("Use singleton flags unchanged, or supply agentera.todoResolveBatch.v1 through --input, preview it, then exact-apply it with the effect SHA-256 and --yes.", developmentCommand('state todo resolve --id qjtrmnpvka --reason "Shipped" --date 2026-07-31 --format json')),
107
107
  "todo.reopen": projection("Use the bare resolved TODO ID, a reason, and a YYYY-MM-DD date; no record input is accepted.", developmentCommand('state todo reopen --id qjtrmnpvka --reason "Scope returned" --date 2026-07-31 --format json')),
108
108
  "docs.create": projection("Remove id and artifact from the input and retry with one schema-valid documentation inventory entry.", developmentCommand("state docs create --input documentation.yaml --format json")),
109
109
  "docs.update": projection("Reread the documentation entry, copy its bare ID to --id, remove CLI-owned fields, and retry.", developmentCommand("state docs update --id qjtrmnpvka --input documentation.yaml --format json")),