specpi 0.10.0 → 0.11.2

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.
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { normalizeCapability, validateCapabilityRegistry } from "./registry.mjs";
6
+ import { saltedSourceRootIdentity, validateVerificationReceipt } from "./verification.mjs";
7
+ import { markdownPathLabel } from "../workflow-controls/task-contract.mjs";
6
8
 
7
9
  export { normalizeCapability } from "./registry.mjs";
8
10
 
@@ -18,7 +20,10 @@ const GATE_PATTERN = /^(?=.*[A-Za-z0-9])[A-Za-z0-9 ._-]{1,60}$/;
18
20
  const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,39}$/;
19
21
  const COLLECTION_MODES = new Set(["on", "off"]);
20
22
  const STATE_ACTIONS = new Set(["select", "decline", "retire", "reopen"]);
21
- const DECISION_ACTIONS = new Set([...STATE_ACTIONS, "merge", "unmerge"]);
23
+ const OUTCOME_VALUES = new Set(["helped", "failed", "not-exercised", "reverted"]);
24
+ const DECISION_ACTIONS = new Set([...STATE_ACTIONS, "merge", "unmerge", "outcome"]);
25
+ const MAX_OUTCOME_NOTE_LENGTH = 240;
26
+ const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,119}$/u;
22
27
  export const WISHLIST_FILENAMES = {
23
28
  events: "tool-wishlist-events.jsonl",
24
29
  decisions: "tool-wishlist-decisions.jsonl",
@@ -59,6 +64,16 @@ function readCapabilityRegistry() {
59
64
 
60
65
  export const CAPABILITY_REGISTRY = readCapabilityRegistry();
61
66
 
67
+ export function wishlistSourceRootIdentity(stateDir, root) {
68
+ const salt = getSalt(pathsFor(stateDir).salt);
69
+
70
+ return saltedSourceRootIdentity(root, salt);
71
+ }
72
+
73
+ export function wishlistSourceRootSalt(stateDir) {
74
+ return getSalt(pathsFor(stateDir).salt);
75
+ }
76
+
62
77
  function registryAliasMap() {
63
78
  const aliases = new Map();
64
79
  for (const item of CAPABILITY_REGISTRY.capabilities) {
@@ -122,7 +137,7 @@ function recoverArchiveTransaction(stateDir) {
122
137
  function assertNotSymlink(file) {
123
138
  try {
124
139
  if (fs.lstatSync(file).isSymbolicLink()) {
125
- throw new Error(`Refusing to use symlinked wishlist state: ${file}`);
140
+ throw new Error(`Refusing to use symlinked wishlist state: ${markdownPathLabel(file)}`);
126
141
  }
127
142
  } catch (error) {
128
143
  if (error.code !== "ENOENT") {
@@ -223,7 +238,7 @@ async function withStateLock(stateDir, signal, operation) {
223
238
  }
224
239
 
225
240
  throw new Error(
226
- `Timed out waiting for ${lock}. Remove it only after confirming no Pi session is updating the wishlist.`,
241
+ `Timed out waiting for ${markdownPathLabel(lock)}. Remove it only after confirming no Pi session is updating the wishlist.`,
227
242
  );
228
243
  }
229
244
 
@@ -303,10 +318,21 @@ function isValidJournal(journal) {
303
318
  journal.changedFiles.every(isValidChangedFilePath))) &&
304
319
  (journal.changedFilesTruncated === undefined || typeof journal.changedFilesTruncated === "boolean") &&
305
320
  typeof journal.version === "string" &&
306
- VERSION_PATTERN.test(journal.version)
321
+ VERSION_PATTERN.test(journal.version) &&
322
+ (journal.receipt === undefined || isValidVerificationReceipt(journal.receipt))
307
323
  );
308
324
  }
309
325
 
326
+ function isValidVerificationReceipt(receipt) {
327
+ try {
328
+ validateVerificationReceipt(receipt);
329
+
330
+ return true;
331
+ } catch {
332
+ return false;
333
+ }
334
+ }
335
+
310
336
  function isStoredDecision(event) {
311
337
  return (
312
338
  event?.schema === 1 &&
@@ -320,7 +346,17 @@ function isStoredDecision(event) {
320
346
  typeof event.note === "string" &&
321
347
  (!Object.hasOwn(event, "evidence") ||
322
348
  (event.action === "reopen" && isValidEvidenceList(event.evidence, REOPEN_EVIDENCE_LIMIT))) &&
323
- (!Object.hasOwn(event, "journal") || (event.action === "retire" && isValidJournal(event.journal)))
349
+ (!Object.hasOwn(event, "journal") || (event.action === "retire" && isValidJournal(event.journal))) &&
350
+ (event.action === "outcome"
351
+ ? OUTCOME_VALUES.has(event.outcome) &&
352
+ typeof event.requestId === "string" &&
353
+ REQUEST_ID_PATTERN.test(event.requestId) &&
354
+ typeof event.previousOutcomeId === "string" &&
355
+ (event.previousOutcomeId === "" || REQUEST_ID_PATTERN.test(event.previousOutcomeId)) &&
356
+ event.targetKey.length > 0
357
+ : !Object.hasOwn(event, "outcome") &&
358
+ !Object.hasOwn(event, "requestId") &&
359
+ !Object.hasOwn(event, "previousOutcomeId"))
324
360
  );
325
361
  }
326
362
 
@@ -466,6 +502,7 @@ function sanitizeJournal(journal) {
466
502
  ...(journal.changedFiles === undefined ? {} : { changedFiles: [...journal.changedFiles] }),
467
503
  changedFilesTruncated: journal.changedFilesTruncated === true,
468
504
  version: sanitizeReportText(journal.version, 40),
505
+ ...(journal.receipt === undefined ? {} : { receipt: validateVerificationReceipt(journal.receipt) }),
469
506
  };
470
507
  // Redaction can empty a value; the sanitized journal must survive its own
471
508
  // reader validation or the whole decision would silently turn invalid.
@@ -644,6 +681,67 @@ export function latestRetirementDecision(decisions, canonicalKey, aliases = buil
644
681
  return latest;
645
682
  }
646
683
 
684
+ export function latestLocalRetirementDecision(decisions, canonicalKey, aliases = buildAliasMap(decisions)) {
685
+ let latest;
686
+ for (const decision of decisions) {
687
+ if (decision.action !== "retire" || resolveAlias(decision.canonicalKey, aliases) !== canonicalKey) {
688
+ continue;
689
+ }
690
+
691
+ if (latest === undefined || timestampMs(decision.timestamp) >= timestampMs(latest.timestamp)) {
692
+ latest = decision;
693
+ }
694
+ }
695
+
696
+ return latest;
697
+ }
698
+
699
+ export function latestOutcomeDecision(decisions, canonicalKey, retirementId, aliases = buildAliasMap(decisions)) {
700
+ let latest;
701
+ for (const decision of decisions) {
702
+ if (
703
+ decision.action !== "outcome" ||
704
+ resolveAlias(decision.canonicalKey, aliases) !== canonicalKey ||
705
+ decision.targetKey !== retirementId
706
+ ) {
707
+ continue;
708
+ }
709
+
710
+ if (latest === undefined || timestampMs(decision.timestamp) >= timestampMs(latest.timestamp)) {
711
+ latest = decision;
712
+ }
713
+ }
714
+
715
+ return latest;
716
+ }
717
+
718
+ export function outcomeSummary(canonicalKey, decisions, aliases = buildAliasMap(decisions)) {
719
+ const counts = {
720
+ helped: 0,
721
+ failed: 0,
722
+ "not-exercised": 0,
723
+ reverted: 0,
724
+ unassessed: 0,
725
+ };
726
+ const retirements = decisions
727
+ .filter(
728
+ (decision) => decision.action === "retire" && resolveAlias(decision.canonicalKey, aliases) === canonicalKey,
729
+ )
730
+ .sort((left, right) => timestampMs(left.timestamp) - timestampMs(right.timestamp));
731
+ const latest = [];
732
+ for (const retirement of retirements) {
733
+ const outcome = latestOutcomeDecision(decisions, canonicalKey, retirement.id, aliases);
734
+ if (!outcome) {
735
+ counts.unassessed += 1;
736
+ } else {
737
+ counts[outcome.outcome] += 1;
738
+ latest.push(outcome);
739
+ }
740
+ }
741
+
742
+ return { ...counts, latest };
743
+ }
744
+
647
745
  export function linkReopenToRetirement(decisions, reopenDecision) {
648
746
  if (reopenDecision?.action !== "reopen") {
649
747
  return undefined;
@@ -710,14 +808,28 @@ function groupsWithState(events, decisions) {
710
808
  const signals = reviewSignals(group.canonicalKey, events, decisions, state).sort(
711
809
  (a, b) => timestampMs(a.timestamp) - timestampMs(b.timestamp),
712
810
  );
811
+ const outcomes = outcomeSummary(group.canonicalKey, decisions);
812
+ const latestRetirement = latestLocalRetirementDecision(decisions, group.canonicalKey);
813
+ const latestOutcome = latestRetirement
814
+ ? latestOutcomeDecision(decisions, group.canonicalKey, latestRetirement.id)
815
+ : undefined;
816
+ const negativeOutcome =
817
+ state === "retired" && latestOutcome && ["failed", "reverted"].includes(latestOutcome.outcome);
818
+ const reviewSignalCount = signals.length + (negativeOutcome ? 1 : 0);
819
+ const reviewTimestamps = [
820
+ ...signals.map((item) => item.timestamp),
821
+ ...(negativeOutcome ? [latestOutcome.timestamp] : []),
822
+ ].sort((left, right) => timestampMs(left) - timestampMs(right));
713
823
 
714
824
  return {
715
825
  ...group,
716
826
  state,
717
- reviewNeeded: signals.length > 0,
718
- reviewSignalCount: signals.length,
719
- reviewFirstSeen: signals[0]?.timestamp,
720
- reviewLastSeen: signals.at(-1)?.timestamp,
827
+ outcomes,
828
+ latestOutcome,
829
+ reviewNeeded: reviewSignalCount > 0,
830
+ reviewSignalCount,
831
+ reviewFirstSeen: reviewTimestamps[0],
832
+ reviewLastSeen: reviewTimestamps.at(-1),
721
833
  };
722
834
  });
723
835
  }
@@ -766,6 +878,7 @@ function renderGroup(lines, group) {
766
878
  `- Suggested fix: ${group.suggestedFix}`,
767
879
  `- First seen: ${day(group.firstSeen)}`,
768
880
  `- Last seen: ${day(group.lastSeen)}`,
881
+ `- Outcomes: helped ${group.outcomes.helped} | failed ${group.outcomes.failed} | not-exercised ${group.outcomes["not-exercised"]} | reverted ${group.outcomes.reverted} | unassessed ${group.outcomes.unassessed}`,
769
882
  );
770
883
  if (group.reviewNeeded) {
771
884
  lines.push(`- Review needed: yes`, `- Unresolved post-retirement signals: ${group.reviewSignalCount}`);
@@ -875,7 +988,8 @@ export function renderWishlist(events, options = {}) {
875
988
  "",
876
989
  "# Loop health",
877
990
  "",
878
- `- Retirements: ${metrics.retirements} | Reopen rate: ${metrics.reopenRate}% | Open reviews: ${metrics.openReviews}`,
991
+ `- Retirements: ${metrics.retirements} | Reopen rate: ${metrics.reopenRateKnown ? `${metrics.reopenRate}%` : "unknown"} | Open reviews: ${metrics.openReviews} | Local cohort rate: ${metrics.reopenRateKnown ? `${metrics.reopenRate}%` : "unknown"} | Baseline reviews: ${metrics.baselineReviews}`,
992
+ `- Outcomes: helped ${metrics.outcomes.helped} | failed ${metrics.outcomes.failed} | not-exercised ${metrics.outcomes["not-exercised"]} | reverted ${metrics.outcomes.reverted} | unassessed ${metrics.outcomes.unassessed}`,
879
993
  metrics.medianDaysToRetire === undefined
880
994
  ? `- Qualification rate: ${metrics.qualificationRate}% of ${metrics.observedGroups} observed gap(s)`
881
995
  : `- Median time to retire: ${metrics.medianDaysToRetire} day(s) | Qualification rate: ${metrics.qualificationRate}% of ${metrics.observedGroups} observed gap(s)`,
@@ -893,7 +1007,9 @@ export function loopMetrics(events, decisions = []) {
893
1007
  // Replays the lifecycle to count only reopens that left the retired state,
894
1008
  // so reopen-from-declined does not inflate the rate.
895
1009
  const states = new Map();
896
- let reopens = 0;
1010
+ let localReopens = 0;
1011
+ let baselineReviews = 0;
1012
+ const localRetirementIds = new Map();
897
1013
  for (const decision of decisions) {
898
1014
  if (!STATE_ACTIONS.has(decision.action)) {
899
1015
  continue;
@@ -902,7 +1018,18 @@ export function loopMetrics(events, decisions = []) {
902
1018
  const key = resolveAlias(decision.canonicalKey, aliases);
903
1019
  const current = states.get(key) ?? (registryCapability(key) ? "retired" : "open");
904
1020
  if (decision.action === "reopen" && current === "retired") {
905
- reopens += 1;
1021
+ const linked = decision.targetKey
1022
+ ? decisions.find((item) => item.action === "retire" && item.id === decision.targetKey)
1023
+ : undefined;
1024
+ if (linked && localRetirementIds.get(key) === linked.id) {
1025
+ localReopens += 1;
1026
+ } else {
1027
+ baselineReviews += 1;
1028
+ }
1029
+
1030
+ localRetirementIds.delete(key);
1031
+ } else if (decision.action === "retire") {
1032
+ localRetirementIds.set(key, decision.id);
906
1033
  }
907
1034
 
908
1035
  states.set(
@@ -938,12 +1065,28 @@ export function loopMetrics(events, decisions = []) {
938
1065
  const qualified = groups.filter(
939
1066
  (group) => group.qualified || group.state === "selected" || group.state === "retired",
940
1067
  ).length;
1068
+ const outcomes = {
1069
+ helped: 0,
1070
+ failed: 0,
1071
+ "not-exercised": 0,
1072
+ reverted: 0,
1073
+ unassessed: 0,
1074
+ };
1075
+ for (const group of groups) {
1076
+ for (const key of Object.keys(outcomes)) {
1077
+ outcomes[key] += group.outcomes[key];
1078
+ }
1079
+ }
941
1080
 
942
1081
  return {
943
1082
  observedGroups: groups.length,
944
1083
  retirements: retirements.length,
945
- reopens,
946
- reopenRate: retirements.length === 0 ? 0 : Math.round((100 * reopens) / retirements.length),
1084
+ reopens: localReopens,
1085
+ localReopens,
1086
+ baselineReviews,
1087
+ reopenRate: retirements.length === 0 ? 0 : Math.min(100, Math.round((100 * localReopens) / retirements.length)),
1088
+ reopenRateKnown: retirements.length > 0,
1089
+ outcomes,
947
1090
  openReviews: retiredGroups.filter((group) => group.reviewNeeded).length,
948
1091
  medianDaysToRetire: median === undefined ? undefined : Math.round(median * 10) / 10,
949
1092
  qualificationRate: groups.length === 0 ? 0 : Math.round((100 * qualified) / groups.length),
@@ -954,7 +1097,11 @@ function renderJournalDecision(lines, decisions, decision, options = {}) {
954
1097
  const stamp = day(decision.timestamp);
955
1098
  if (options.compact) {
956
1099
  const journal = decision.journal;
957
- const proof = journal ? ` · gates: ${journal.gates.join(", ")} · v${journal.version}` : "";
1100
+ const proof = journal
1101
+ ? ` · gates: ${journal.gates.join(", ")} · v${journal.version}`
1102
+ : decision.action === "outcome"
1103
+ ? ` · outcome: ${decision.outcome}`
1104
+ : "";
958
1105
  lines.push(
959
1106
  `- ${stamp} ${decision.action} \`${decision.canonicalKey}\` — ${markdownText(decision.note)}${proof}`,
960
1107
  );
@@ -962,10 +1109,8 @@ function renderJournalDecision(lines, decisions, decision, options = {}) {
962
1109
  return;
963
1110
  }
964
1111
 
965
- lines.push(
966
- `### ${decision.action === "retire" ? "Retired" : "Reopened"} ${stamp}`,
967
- `- Decision: \`${decision.id.slice(0, 8)}\``,
968
- );
1112
+ const title = decision.action === "retire" ? "Retired" : decision.action === "reopen" ? "Reopened" : "Outcome";
1113
+ lines.push(`### ${title} ${stamp}`, `- Decision: \`${decision.id.slice(0, 8)}\``);
969
1114
  if (decision.action === "retire") {
970
1115
  const journal = decision.journal;
971
1116
  if (journal) {
@@ -982,7 +1127,7 @@ function renderJournalDecision(lines, decisions, decision, options = {}) {
982
1127
  } else {
983
1128
  lines.push(`- Note: ${markdownText(decision.note)}`);
984
1129
  }
985
- } else {
1130
+ } else if (decision.action === "reopen") {
986
1131
  const linked = linkReopenToRetirement(decisions, decision);
987
1132
  if (linked) {
988
1133
  lines.push(`- Linked retirement: \`${linked.id.slice(0, 8)}\` (${day(linked.timestamp)})`);
@@ -993,6 +1138,15 @@ function renderJournalDecision(lines, decisions, decision, options = {}) {
993
1138
  }
994
1139
 
995
1140
  lines.push(`- Note: ${markdownText(decision.note)}`);
1141
+ } else {
1142
+ lines.push(`- Retirement: \`${decision.targetKey.slice(0, 8)}\``, `- Result: **${decision.outcome}**`);
1143
+ if (decision.previousOutcomeId) {
1144
+ lines.push(`- Corrects outcome: \`${decision.previousOutcomeId.slice(0, 8)}\``);
1145
+ }
1146
+
1147
+ if (decision.note) {
1148
+ lines.push(`- Note: ${markdownText(decision.note)}`);
1149
+ }
996
1150
  }
997
1151
 
998
1152
  lines.push("");
@@ -1000,7 +1154,9 @@ function renderJournalDecision(lines, decisions, decision, options = {}) {
1000
1154
 
1001
1155
  export function renderWishlistHistory(events, decisions = [], requestedKey) {
1002
1156
  const aliases = buildAliasMap(decisions);
1003
- const timeline = decisions.filter((decision) => decision.action === "retire" || decision.action === "reopen");
1157
+ const timeline = decisions.filter(
1158
+ (decision) => decision.action === "retire" || decision.action === "reopen" || decision.action === "outcome",
1159
+ );
1004
1160
  const lines = [
1005
1161
  "# Improvement journal",
1006
1162
  "",
@@ -1008,7 +1164,7 @@ export function renderWishlistHistory(events, decisions = [], requestedKey) {
1008
1164
  "",
1009
1165
  ];
1010
1166
  if (timeline.length === 0) {
1011
- lines.push("No retirements or reopens recorded yet.", "");
1167
+ lines.push("No retirements or reopens recorded yet; no outcomes have been assessed.", "");
1012
1168
 
1013
1169
  return lines.join("\n");
1014
1170
  }
@@ -1025,7 +1181,7 @@ export function renderWishlistHistory(events, decisions = [], requestedKey) {
1025
1181
  lines.push(
1026
1182
  `## ${markdownText(title)}`,
1027
1183
  "",
1028
- `\`${key}\` — ${scoped.length} lifecycle decision(s), oldest first.`,
1184
+ `\`${key}\` — ${scoped.length} lifecycle and outcome decision(s), oldest first.`,
1029
1185
  "",
1030
1186
  );
1031
1187
  for (const decision of scoped) {
@@ -1299,6 +1455,10 @@ export async function appendWishlistDecision(options) {
1299
1455
  evidence,
1300
1456
  journal,
1301
1457
  linkedRetirementId,
1458
+ outcome,
1459
+ requestId,
1460
+ previousOutcomeId,
1461
+ precondition,
1302
1462
  signal,
1303
1463
  now = new Date().toISOString(),
1304
1464
  maxDecisionFileBytes = MAX_DECISION_FILE_BYTES,
@@ -1307,6 +1467,8 @@ export async function appendWishlistDecision(options) {
1307
1467
  throw new Error(`Unknown wishlist action: ${action}`);
1308
1468
  }
1309
1469
 
1470
+ const normalizedPreviousOutcomeId = previousOutcomeId ?? "";
1471
+
1310
1472
  if (journal !== undefined && action !== "retire") {
1311
1473
  throw new Error("A retirement journal is only valid on retire decisions");
1312
1474
  }
@@ -1317,11 +1479,38 @@ export async function appendWishlistDecision(options) {
1317
1479
  );
1318
1480
  }
1319
1481
 
1482
+ if (action === "outcome") {
1483
+ if (!OUTCOME_VALUES.has(outcome)) {
1484
+ throw new Error("Outcome must be helped, failed, not-exercised, or reverted");
1485
+ }
1486
+
1487
+ if (typeof requestId !== "string" || !REQUEST_ID_PATTERN.test(requestId)) {
1488
+ throw new Error("Outcome request ID is invalid");
1489
+ }
1490
+
1491
+ if (
1492
+ typeof normalizedPreviousOutcomeId !== "string" ||
1493
+ (normalizedPreviousOutcomeId && !REQUEST_ID_PATTERN.test(normalizedPreviousOutcomeId))
1494
+ ) {
1495
+ throw new Error("Previous outcome ID is invalid");
1496
+ }
1497
+
1498
+ if (!targetKey || typeof targetKey !== "string") {
1499
+ throw new Error("Outcome must link to an exact local retirement decision");
1500
+ }
1501
+ } else if (outcome !== undefined || requestId !== undefined || previousOutcomeId !== undefined) {
1502
+ throw new Error("Outcome fields are only valid on outcome decisions");
1503
+ }
1504
+
1320
1505
  const journalValue = journal !== undefined ? sanitizeJournal(journal) : undefined;
1321
1506
  const evidenceValue =
1322
1507
  evidence !== undefined ? sanitizeEvidenceList(evidence, REOPEN_EVIDENCE_LIMIT, "Reopen evidence") : undefined;
1323
1508
 
1324
1509
  return withStateLock(stateDir, signal, async () => {
1510
+ if (action === "outcome" && readCollectionMode(stateDir) !== "on") {
1511
+ throw new Error("Local wishlist collection must be on before recording an outcome");
1512
+ }
1513
+
1325
1514
  if (!Number.isFinite(timestampMs(now))) {
1326
1515
  throw new Error("Decision timestamp is invalid");
1327
1516
  }
@@ -1340,7 +1529,72 @@ export async function appendWishlistDecision(options) {
1340
1529
  throw new Error(`Unknown wishlist gap: ${key}`);
1341
1530
  }
1342
1531
 
1343
- if (STATE_ACTIONS.has(action)) {
1532
+ if (typeof precondition === "function") {
1533
+ await precondition({
1534
+ files,
1535
+ events: parsed.events,
1536
+ decisions: decisionData.decisions,
1537
+ canonicalKey: key,
1538
+ });
1539
+ }
1540
+
1541
+ if (action === "outcome") {
1542
+ const existing = decisionData.decisions.find(
1543
+ (decision) => decision.action === "outcome" && decision.requestId === requestId,
1544
+ );
1545
+ if (existing) {
1546
+ if (
1547
+ resolveAlias(existing.canonicalKey, aliases) !== key ||
1548
+ existing.targetKey !== targetKey ||
1549
+ existing.outcome !== outcome ||
1550
+ existing.previousOutcomeId !== normalizedPreviousOutcomeId ||
1551
+ existing.note !== sanitizeReportText(note, MAX_OUTCOME_NOTE_LENGTH)
1552
+ ) {
1553
+ throw new Error("Outcome request ID was already used for a different decision");
1554
+ }
1555
+
1556
+ return {
1557
+ action,
1558
+ decisionId: existing.id,
1559
+ canonicalKey: existing.canonicalKey,
1560
+ targetKey: existing.targetKey,
1561
+ reportPath: files.report,
1562
+ report: writeReport(
1563
+ files.report,
1564
+ parsed.events,
1565
+ decisionData.decisions,
1566
+ parsed.invalidLines,
1567
+ decisionData.invalidLines,
1568
+ now,
1569
+ ),
1570
+ idempotent: true,
1571
+ };
1572
+ }
1573
+
1574
+ const current = currentStateFor(key, decisionData.decisions);
1575
+ if (current !== "retired") {
1576
+ throw new Error(`Outcome can only be recorded for a retired wishlist gap; ${key} is ${current}`);
1577
+ }
1578
+
1579
+ const retirement = decisionData.decisions.find(
1580
+ (decision) => decision.action === "retire" && decision.id === targetKey,
1581
+ );
1582
+ const latestRetirement = latestLocalRetirementDecision(decisionData.decisions, key, aliases);
1583
+ if (!retirement || retirement !== latestRetirement || !retirement.journal?.receipt) {
1584
+ throw new Error("Outcome must link to the exact latest local retirement receipt");
1585
+ }
1586
+
1587
+ if (retirement.journal.receipt.gapId !== key) {
1588
+ throw new Error("Outcome retirement receipt is bound to a different wishlist gap");
1589
+ }
1590
+
1591
+ const previous = latestOutcomeDecision(decisionData.decisions, key, retirement.id, aliases);
1592
+ if ((previous?.id ?? "") !== previousOutcomeId) {
1593
+ throw new Error("Outcome is stale; refresh the latest retirement outcome before retrying");
1594
+ }
1595
+
1596
+ target = targetKey;
1597
+ } else if (STATE_ACTIONS.has(action)) {
1344
1598
  const current = currentStateFor(key, decisionData.decisions);
1345
1599
  if (!validateStateTransition(action, current)) {
1346
1600
  throw new Error(`Cannot ${action} ${key} while its state is ${current}`);
@@ -1400,6 +1654,13 @@ export async function appendWishlistDecision(options) {
1400
1654
  note: sanitizeReportText(note, 240),
1401
1655
  ...(evidenceValue ? { evidence: evidenceValue } : {}),
1402
1656
  ...(journalValue ? { journal: journalValue } : {}),
1657
+ ...(action === "outcome"
1658
+ ? {
1659
+ outcome,
1660
+ requestId,
1661
+ previousOutcomeId: normalizedPreviousOutcomeId,
1662
+ }
1663
+ : {}),
1403
1664
  };
1404
1665
  appendBounded(
1405
1666
  files.decisions,