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.
- package/CHANGELOG.md +169 -150
- package/LICENSE +21 -21
- package/NPM_RELEASE.md +110 -110
- package/README.md +223 -155
- package/SECURITY.md +86 -85
- package/SECURITY_MODEL.md +123 -107
- package/THIRD_PARTY.md +61 -61
- package/browser-runtime/package-lock.json +86 -86
- package/browser-runtime/package.json +15 -15
- package/extensions/command-guard/powershell-parser.ps1 +47 -47
- package/extensions/spec.ts +236 -10
- package/extensions/tool-wishlist/capabilities.json +114 -114
- package/extensions/tool-wishlist/core.mjs +285 -24
- package/extensions/tool-wishlist/index.ts +939 -90
- package/extensions/tool-wishlist/verification.mjs +810 -0
- package/extensions/workflow-controls/challenge.mjs +139 -13
- package/extensions/workflow-controls/index.ts +811 -35
- package/extensions/workflow-controls/scope.mjs +41 -5
- package/extensions/workflow-controls/smoke.mjs +55 -0
- package/extensions/workflow-controls/task-contract.mjs +439 -0
- package/package.json +98 -98
- package/scripts/check-package.mjs +3 -0
- package/scripts/check-pi-package.mjs +103 -4
- package/scripts/pi-test-harness.mjs +309 -0
- package/scripts/specpi.mjs +31 -2
- package/skills/specpi-improve/SKILL.md +60 -54
- package/specpi +0 -0
- package/templates/AGENTS.md +25 -23
- package/templates/settings.json +10 -10
- package/themes/specpi-spec.json +96 -96
|
@@ -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
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
966
|
-
|
|
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(
|
|
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 (
|
|
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,
|