specpi 0.30.0 → 0.30.1

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.
@@ -66,7 +66,20 @@ function rotate(file) {
66
66
  export function summarize(entries) {
67
67
  const lines = Array.isArray(entries) ? entries : [];
68
68
  const bySystem = {};
69
+ let abstentions = 0;
70
+ let elisions = 0;
69
71
  for (const entry of lines) {
72
+ if (entry?.sent === false) {
73
+ abstentions += 1;
74
+ continue;
75
+ }
76
+
77
+ // Older lines have no effect tags. Positive savings, not `applied`, distinguish a
78
+ // real shortening from an untrusted-content banner sharing a retention request.
79
+ if (entry?.effects?.includes("elision") || (entry?.system === "retention" && entry?.savedBytes > 0)) {
80
+ elisions += 1;
81
+ }
82
+
70
83
  const name = typeof entry?.system === "string" ? entry.system : "unknown";
71
84
  const bucket = (bySystem[name] ??= {
72
85
  calls: 0,
@@ -91,14 +104,15 @@ export function summarize(entries) {
91
104
  const totals = Object.values(bySystem);
92
105
 
93
106
  return {
94
- calls: lines.length,
107
+ calls: lines.length - abstentions,
108
+ abstentions,
95
109
  failed: totals.reduce((sum, item) => sum + item.failed, 0),
96
110
  applied: totals.reduce((sum, item) => sum + item.applied, 0),
97
111
  savedBytes: totals.reduce((sum, item) => sum + item.savedBytes, 0),
98
112
  stateBytes: totals.reduce((sum, item) => sum + item.stateBytes, 0),
99
113
  // Named for what retention does, because it is the only system that shortens anything and
100
114
  // the number is meaningless averaged with systems that cannot.
101
- elisions: bySystem.retention?.applied ?? 0,
115
+ elisions,
102
116
  bytesDropped: bySystem.retention?.savedBytes ?? 0,
103
117
  bySystem,
104
118
  };
@@ -1,21 +1,5 @@
1
- // System 2: triage a capability-gap report before it is written.
2
- //
3
- // This fixes two real defects in the improvement loop rather than saving tokens.
4
- //
5
- // Fragmentation: `aggregateEvents` groups by exact `canonicalKey` string, with manual alias
6
- // decisions as the only correction, and qualification needs `priority >= blocked || occurrences
7
- // >= 2`. A genuinely recurring problem that the model names differently each time fragments into
8
- // singletons and can stay permanently unqualified — it never reaches the human at
9
- // /harness-improvement. Lexical matching cannot fix that; semantic clustering can.
10
- //
11
- // Self-reported severity: `priority` sums IMPACT_WEIGHT[event.impact], and `impact` is reported by
12
- // the model about its own gap. The ranking that decides what a human sees rests on the least
13
- // reliable field in the record. An independent score is recorded *alongside* it, never over it, so
14
- // the human still sees what the model claimed.
15
- //
16
- // Authority is unchanged. Only an exact human selection through /harness-improvement authorizes a
17
- // wishlist-sourced change, and nothing here writes a decision.
18
-
1
+ // An advisory second opinion on the report, not independent evidence of the claimed difficulty.
2
+ // Matches are suggestions for human merging; they never change canonical keys or selection authority.
19
3
  import { choice, noul, score } from "../client.mjs";
20
4
  import { choiceValue, nounTrue, scoreLevel } from "../gate.mjs";
21
5
  import { compact } from "../sanitize.mjs";
@@ -25,7 +9,6 @@ export const IMPACT_LEVELS = Object.freeze([
25
9
  "Moderate: real friction that cost time or forced a detour",
26
10
  "Blocked: the task could not be completed as asked",
27
11
  ]);
28
-
29
12
  export const FIX_KINDS = Object.freeze({
30
13
  tool: "A new or changed tool would solve it",
31
14
  skill: "A skill or documented procedure would solve it",
@@ -34,85 +17,69 @@ export const FIX_KINDS = Object.freeze({
34
17
  bug: "Something is broken and should be repaired",
35
18
  unknown: "Not clear from what was observed",
36
19
  });
37
-
38
20
  export const NEW_CLUSTER = "__new__";
39
-
40
- /** The Choice ceiling is 255; above the cap, rank by similarity first and choose among the top N. */
41
- export const MAX_CLUSTER_OPTIONS = 200;
21
+ export const MAX_CLUSTER_OPTIONS = 4;
42
22
 
43
23
  function similarity(a, b) {
44
- const left = new Set(
45
- String(a)
46
- .toLowerCase()
47
- .split(/[^a-z0-9]+/u)
48
- .filter(Boolean),
49
- );
50
- const right = new Set(
51
- String(b)
52
- .toLowerCase()
53
- .split(/[^a-z0-9]+/u)
54
- .filter(Boolean),
55
- );
56
- if (left.size === 0 || right.size === 0) {
57
- return 0;
58
- }
59
-
60
- let shared = 0;
61
- for (const token of left) {
62
- if (right.has(token)) {
63
- shared += 1;
64
- }
65
- }
66
-
67
- return shared / Math.max(left.size, right.size);
24
+ const tokens = (text) =>
25
+ new Set(
26
+ String(text)
27
+ .toLowerCase()
28
+ .split(/[^a-z0-9]+/u)
29
+ .filter(Boolean),
30
+ );
31
+ const left = tokens(a);
32
+ const right = tokens(b);
33
+ const shared = [...left].filter((token) => right.has(token)).length;
34
+
35
+ return shared / Math.max(left.size, right.size, 1);
68
36
  }
69
37
 
70
- /**
71
- * TypeSafe's own two-stage pattern for high cardinality: score candidates cheaply, then make one
72
- * explicit choice among the survivors. Below the cap every key is offered.
73
- */
74
- export function clusterOptions(existing, gap) {
75
- const keys = [...new Set(existing.map((item) => item.canonicalKey).filter(Boolean))];
76
- const probe = `${gap?.capability ?? ""} ${gap?.scenario ?? ""}`;
77
- const ranked =
78
- keys.length <= MAX_CLUSTER_OPTIONS
79
- ? keys
80
- : keys
81
- .map((key) => ({ key, rank: similarity(key, probe) }))
82
- .sort((a, b) => b.rank - a.rank)
83
- .slice(0, MAX_CLUSTER_OPTIONS)
84
- .map((item) => item.key);
38
+ export function shortlist(existing, report) {
39
+ const probe = `${report?.capability ?? ""} ${report?.scenario ?? ""}`;
40
+ const unique = [...new Map(existing.map((item) => [item.canonicalKey, item])).values()];
85
41
 
86
- const criteria = { [NEW_CLUSTER]: "This is a distinct problem not already on the list" };
87
- for (const key of ranked) {
88
- const match = existing.find((item) => item.canonicalKey === key);
89
- criteria[key] = compact(match?.title ?? key, 90);
90
- }
42
+ return unique
43
+ .map((item, index) => ({ item, index, rank: similarity(`${item.title} ${item.canonicalKey}`, probe) }))
44
+ .sort((a, b) => b.rank - a.rank || a.index - b.index)
45
+ .slice(0, MAX_CLUSTER_OPTIONS)
46
+ .map(({ item }) => item);
47
+ }
91
48
 
92
- return criteria;
49
+ export function clusterOptions(existing) {
50
+ return Object.fromEntries([
51
+ [NEW_CLUSTER, "This is a distinct problem not already on the list"],
52
+ ...existing.map((_item, index) => [
53
+ `cluster_${index}`,
54
+ `The known problem with ID cluster_${index} in the state`,
55
+ ]),
56
+ ]);
93
57
  }
94
58
 
95
59
  export function buildInput({ gap, existing = [] }) {
96
60
  return {
97
61
  capability: compact(gap?.capability ?? "", 120),
98
- scenario: compact(gap?.scenario ?? "", 200),
62
+ scenario: compact(gap?.scenario ?? "", 180),
99
63
  limitation: compact(gap?.limitation ?? "", 160),
100
- workaround: compact(gap?.workaround ?? "", 160),
101
- claimedImpact: compact(gap?.impact ?? "", 20),
102
- knownProblems: existing.slice(0, 8).map((item) => compact(item.title ?? item.canonicalKey, 60)),
64
+ workaround: compact(gap?.workaround ?? "", 80),
65
+ claimedImpact: compact(gap?.impact ?? "", 18),
66
+ knownProblems: existing.map((item, index) => ({ id: `cluster_${index}`, title: compact(item.title, 80) })),
103
67
  };
104
68
  }
105
69
 
106
- export function questions({ gap, existing = [] }) {
70
+ export function questions({ existing = [] } = {}) {
107
71
  return {
108
72
  cluster: choice(
109
73
  "Which known problem is this the same underlying problem as, if any?",
110
- clusterOptions(existing, gap),
74
+ clusterOptions(existing),
75
+ ),
76
+ independent_impact: score(
77
+ "Based only on this reported account, how badly did the limitation obstruct the task?",
78
+ IMPACT_LEVELS,
111
79
  ),
112
- independent_impact: score("How badly did this actually obstruct the task?", IMPACT_LEVELS),
113
80
  suggested_fix: choice("What kind of change would address this?", FIX_KINDS),
114
81
  contains_secret_or_path: noul(
115
- "This report contains a credential, an absolute filesystem path, or other machine-specific detail",
82
+ "This report contains sensitive or machine-specific details, including details replaced by redaction markers",
116
83
  ),
117
84
  is_transient_or_user_error: noul(
118
85
  "This was a one-off failure or a mistake in how the task was asked, not a reusable gap in the harness",
@@ -120,20 +87,16 @@ export function questions({ gap, existing = [] }) {
120
87
  };
121
88
  }
122
89
 
123
- const IMPACT_NAMES = Object.freeze(["minor", "moderate", "blocked"]);
124
-
125
- /**
126
- * Advice, in the shape the caller applies it. `blockForSanitization` is the only field that stops
127
- * a write, and it stops it to ask the model to rewrite its own text — never to discard the report.
128
- */
129
- export function decide(answers) {
90
+ export function decide(answers, existing = []) {
130
91
  const cluster = choiceValue(answers?.cluster, "gap");
92
+ const match = existing.find((_item, index) => cluster === `cluster_${index}`);
131
93
  const level = scoreLevel(answers?.independent_impact, "gap");
94
+ const suggestedFix = choiceValue(answers?.suggested_fix, "gap");
132
95
 
133
96
  return {
134
- canonicalKey: cluster && cluster !== NEW_CLUSTER ? cluster : undefined,
135
- independentImpact: level === undefined ? undefined : IMPACT_NAMES[level],
136
- suggestedFix: choiceValue(answers?.suggested_fix, "gap"),
97
+ matchedKey: match?.canonicalKey,
98
+ impactOpinion: ["minor", "degraded", "blocked"][level],
99
+ suggestedFix: Object.hasOwn(FIX_KINDS, suggestedFix ?? "") ? suggestedFix : undefined,
137
100
  blockForSanitization: nounTrue(answers?.contains_secret_or_path, "gap"),
138
101
  transient: nounTrue(answers?.is_transient_or_user_error, "gap"),
139
102
  };
@@ -19,12 +19,10 @@
19
19
  // ungated, and specifically `relevance-low-confidence` -- Jev answered, and reported a confidence
20
20
  // below the calibrated 0.60.
21
21
  //
22
- // That is not a threshold to lower. On a deliberately clear-cut case -- a listing of vendor icons
23
- // during a changelog edit -- confidence is 0.75 to 0.85, so the gate is reachable. On the real
24
- // reads of a 120-step repair chain, where each result feeds the next step, the model is genuinely
25
- // unsure whether the output is spent, and it says so. Moving the threshold under a confidence the
26
- // model did not have would be fitting the gate to make it fire, and this system's asymmetry is the
27
- // reason not to: carrying a result costs tokens, dropping the wrong one costs the task.
22
+ // Those historical calls did not reliably include the objective or result samples: the old
23
+ // integration could lose both. They establish no benefit, but cannot isolate classifier quality.
24
+ // The repaired inputs need new controlled measurements; the thresholds remain unchanged.
25
+ // Carrying a result costs tokens, while dropping the wrong evidence can cost the task.
28
26
  //
29
27
  // So the honest statement is that retention does not pay off on this workload, and it is now
30
28
  // possible to say that from a report rather than infer it from a cost delta. Whether it pays off on
@@ -39,6 +37,7 @@ import { compact, outline } from "../sanitize.mjs";
39
37
  /** Results below this never justify a call: the saving cannot exceed the overhead. */
40
38
  export const MIN_RESULT_BYTES = 4096;
41
39
 
40
+ // Shell commands are excluded: success does not prove read-only execution or safe re-running.
42
41
  // Only tools that observe. A write or edit result is a record of a mutation, and eliding it would
43
42
  // hide what the session did to the worktree from every later turn.
44
43
  //
@@ -57,8 +56,6 @@ export const ELIGIBLE_TOOLS = Object.freeze(
57
56
  "grep",
58
57
  "find",
59
58
  "ls",
60
- "bash",
61
- "powershell",
62
59
  "web_search",
63
60
  "fetch_content",
64
61
  "get_search_content",
@@ -1,13 +1,7 @@
1
- // System 4: pre-rank the sources a delegation batch is about to snapshot.
2
- //
3
- // specpi-delegation freezes up to 200 files / 8 MiB for a child that can read the selection and
4
- // nothing else. A wrong selection costs twice: the snapshot itself, and a child that cannot answer
5
- // the question it was given. Ranking is advisory — the parent still chooses, the ceilings are
6
- // unchanged, and this lives in SpecPi's advisor rather than in the published package, so
7
- // specpi-delegation keeps its one-sentence boundary and gains no network dependency.
8
-
9
- import { choice, noul, score } from "../client.mjs";
10
- import { choiceValue, nounFalse, nounTrue, scoreLevel } from "../gate.mjs";
1
+ // Rank each delegation job's selected sources, never select or remove sources. Paths are state,
2
+ // not question instructions. The 1 KiB evidence budget may cause a large job to abstain locally.
3
+ import { noul, score } from "../client.mjs";
4
+ import { nounFalse, nounTrue, scoreLevel } from "../gate.mjs";
11
5
  import { compact } from "../sanitize.mjs";
12
6
 
13
7
  export const RELEVANCE_LEVELS = Object.freeze([
@@ -15,35 +9,29 @@ export const RELEVANCE_LEVELS = Object.freeze([
15
9
  "Possibly relevant background",
16
10
  "Very likely to contain the answer",
17
11
  ]);
12
+ export const MAX_CANDIDATES = 40;
18
13
 
19
- /** Paths and shape only. File contents are exactly what the child is being given access to read. */
20
- export function buildInput({ question, candidates }) {
14
+ export function buildInput({ question, mode, candidates }) {
21
15
  return {
22
- question: compact(question ?? "", 200),
23
- candidates: candidates.slice(0, 40).map((item) => ({
24
- path: compact(item.path, 80),
25
- bytes: item.bytes ?? 0,
26
- })),
16
+ question: compact(question ?? "", 220),
17
+ mode,
18
+ candidates: candidates.map((item, index) => ({ id: `source_${index}`, path: item.path })),
27
19
  };
28
20
  }
29
21
 
30
- /**
31
- * One Score per candidate in a single call. Questions are evaluated in parallel against one state,
32
- * so forty scores cost one state rather than forty, and output is free.
33
- */
34
22
  export function questions({ candidates }) {
23
+ if (candidates.length > MAX_CANDIDATES) {
24
+ return {};
25
+ }
26
+
35
27
  const asked = {
36
- job_mode: choice("What is being asked of the child session?", {
37
- review: "Check finished work against stated requirements",
38
- scout: "Answer one evidence question over the sources",
39
- }),
40
28
  worth_delegating: noul(
41
- "This is a self-contained evidence question that a child session with read-only access could answer",
29
+ "Given its declared mode, can a read-only child answer this job's question from the selected sources?",
42
30
  ),
43
31
  };
44
- for (const [index, item] of candidates.slice(0, 40).entries()) {
32
+ for (let index = 0; index < candidates.length; index += 1) {
45
33
  asked[`source_${index}`] = score(
46
- `How likely is ${compact(item.path, 80)} to contain what the question needs?`,
34
+ `How likely is candidate source_${index} in the state to contain what the question needs?`,
47
35
  RELEVANCE_LEVELS,
48
36
  );
49
37
  }
@@ -51,41 +39,24 @@ export function questions({ candidates }) {
51
39
  return asked;
52
40
  }
53
41
 
54
- /**
55
- * Ordering only. Ungated scores keep their original position rather than being dropped, so a
56
- * low-confidence run degrades to the caller's own ordering instead of a truncated selection.
57
- */
42
+ /** Only gated slots are reordered. Ungated sources stay at their original indices. */
58
43
  export function decide(answers, candidates) {
59
- const ranked = candidates.slice(0, 40).map((item, index) => ({
60
- ...item,
44
+ const ranked = candidates.map((item, index) => ({
45
+ item,
61
46
  level: scoreLevel(answers?.[`source_${index}`], "sources"),
62
47
  position: index,
63
48
  }));
64
- ranked.sort((a, b) => {
65
- if (a.level === b.level) {
66
- return a.position - b.position;
67
- }
68
-
69
- if (a.level === undefined) {
70
- return 1;
71
- }
72
-
73
- if (b.level === undefined) {
74
- return -1;
75
- }
76
-
77
- return b.level - a.level;
78
- });
49
+ const gated = ranked.filter((item) => item.level !== undefined && item.level <= 2);
50
+ const sorted = [...gated].sort((a, b) => b.level - a.level || a.position - b.position);
51
+ const ordered = [...candidates];
52
+ for (const [index, slot] of gated.entries()) {
53
+ ordered[slot.position] = sorted[index].item;
54
+ }
79
55
 
80
56
  return {
81
- ordered: ranked.map(({ level, position, ...item }) => item),
82
- unrelated: ranked.filter((item) => item.level === 0).map((item) => item.path),
83
- jobMode: choiceValue(answers?.job_mode, "sources"),
57
+ ordered,
58
+ unrelated: gated.filter((item) => item.level === 0).map((item) => item.item.path),
84
59
  worthDelegating: nounTrue(answers?.worth_delegating, "sources"),
85
- // The useful half. A confident yes tells the caller what it already decided by calling
86
- // delegate; a confident no is a warning worth having before up to 200 files and 8 MiB are
87
- // frozen for a child that cannot answer the question anyway. Both were computed and thrown
88
- // away, and output is free, so they were already paid for.
89
60
  notWorthDelegating: nounFalse(answers?.worth_delegating, "sources"),
90
61
  };
91
62
  }
Binary file
@@ -471,6 +471,53 @@ function sanitizeGap(gap) {
471
471
  };
472
472
  }
473
473
 
474
+ // This is a second opinion on the same report, not corroborating evidence. It cannot affect
475
+ // identity, priority, qualification, human selection, or retirement.
476
+ function sanitizeAssessment(value, knownKeys) {
477
+ if (!value || typeof value !== "object") {
478
+ return undefined;
479
+ }
480
+
481
+ const result = { source: "jev", basis: "reported-observation" };
482
+ if (["minor", "degraded", "blocked"].includes(value.impactOpinion)) {
483
+ result.impactOpinion = value.impactOpinion;
484
+ }
485
+
486
+ if (["tool", "skill", "prompt", "config", "bug", "unknown"].includes(value.suggestedFix)) {
487
+ result.suggestedFix = value.suggestedFix;
488
+ }
489
+
490
+ if (
491
+ typeof value.matchedKey === "string" &&
492
+ /^[a-z0-9][a-z0-9-]{0,119}$/u.test(value.matchedKey) &&
493
+ (!knownKeys || knownKeys.has(value.matchedKey))
494
+ ) {
495
+ result.matchedKey = value.matchedKey;
496
+ }
497
+
498
+ if (value.transient === true) {
499
+ result.transient = true;
500
+ }
501
+
502
+ return Object.keys(result).length > 2 ? result : undefined;
503
+ }
504
+
505
+ /** Read only bounded, sanitized local improvement records; never Pi state or private evidence. */
506
+ export function wishlistTriageCandidates(stateDir) {
507
+ if (readCollectionMode(stateDir) !== "on") {
508
+ return [];
509
+ }
510
+
511
+ const files = pathsFor(stateDir);
512
+ const { events } = readEventsFile(files.events);
513
+ const { decisions } = readDecisionsFile(files.decisions);
514
+
515
+ return aggregateEvents(events, { decisions }).map((item) => ({
516
+ canonicalKey: item.canonicalKey,
517
+ title: sanitizeReportText(item.title, 80),
518
+ }));
519
+ }
520
+
474
521
  function sanitizeEvidenceList(value, maxItems, label) {
475
522
  if (!isValidEvidenceList(value, maxItems)) {
476
523
  throw new Error(`${label} must contain 1 to ${maxItems} strings of at most 240 characters`);
@@ -654,6 +701,10 @@ export function aggregateEvents(events, options = {}) {
654
701
  scenarios: recentUnique("scenario"),
655
702
  limitations: recentUnique("limitation"),
656
703
  workarounds: recentUnique("workaround"),
704
+ assessment: [...ordered]
705
+ .reverse()
706
+ .map((event) => sanitizeAssessment(event.assessment))
707
+ .find(Boolean),
657
708
  };
658
709
  })
659
710
  .sort(
@@ -884,6 +935,26 @@ function renderGroup(lines, group) {
884
935
  lines.push(`- Review needed: yes`, `- Unresolved post-retirement signals: ${group.reviewSignalCount}`);
885
936
  }
886
937
 
938
+ if (group.assessment) {
939
+ const opinion = group.assessment;
940
+ lines.push("", "**Jev advisory opinion** (based on the reported account, not independent evidence)");
941
+ if (opinion.impactOpinion) {
942
+ lines.push(`- Impact opinion: ${opinion.impactOpinion}`);
943
+ }
944
+
945
+ if (opinion.suggestedFix) {
946
+ lines.push(`- Suggested fix opinion: ${opinion.suggestedFix}`);
947
+ }
948
+
949
+ if (opinion.matchedKey) {
950
+ lines.push(`- Possible existing cluster: \`${opinion.matchedKey}\` (human merge decision required)`);
951
+ }
952
+
953
+ if (opinion.transient) {
954
+ lines.push("- May be transient or user error; review the original observation.");
955
+ }
956
+ }
957
+
887
958
  if (group.scenarios.length) {
888
959
  lines.push("", "**Observed needs**", ...group.scenarios.map((value) => `- ${markdownText(value)}`));
889
960
  }
@@ -1338,12 +1409,20 @@ export async function recordCapabilityGap(options) {
1338
1409
  runId,
1339
1410
  cwd,
1340
1411
  gap,
1412
+ assessment,
1341
1413
  signal,
1342
1414
  now = new Date().toISOString(),
1343
1415
  maxEventFileBytes = MAX_EVENT_FILE_BYTES,
1416
+ isCurrent = () => true,
1417
+ assessmentIsCurrent = () => true,
1418
+ onRecorded = () => {},
1344
1419
  } = options;
1345
1420
 
1346
1421
  return withStateLock(stateDir, signal, async () => {
1422
+ if (signal?.aborted || !isCurrent()) {
1423
+ throw new Error("The task changed before the report could be recorded");
1424
+ }
1425
+
1347
1426
  if (readCollectionMode(stateDir) !== "on") {
1348
1427
  throw new Error("Local wishlist collection must be explicitly on before recording observations");
1349
1428
  }
@@ -1371,6 +1450,10 @@ export async function recordCapabilityGap(options) {
1371
1450
  resolveAlias(event.observedKey ?? event.canonicalKey, aliases) === canonicalKey &&
1372
1451
  event.runHash === runHash,
1373
1452
  );
1453
+ const advisory = sanitizeAssessment(
1454
+ assessmentIsCurrent() ? assessment : undefined,
1455
+ new Set(canonicalizeEvents(parsed.events, decisionData.decisions).map((event) => event.canonicalKey)),
1456
+ );
1374
1457
  if (!duplicate) {
1375
1458
  const event = {
1376
1459
  schema: 1,
@@ -1381,6 +1464,7 @@ export async function recordCapabilityGap(options) {
1381
1464
  runHash,
1382
1465
  projectHash,
1383
1466
  ...sanitized,
1467
+ ...(advisory ? { assessment: advisory } : {}),
1384
1468
  ...(regression ? { regression: true } : {}),
1385
1469
  };
1386
1470
  parsed.bytes = appendBounded(
@@ -1391,6 +1475,7 @@ export async function recordCapabilityGap(options) {
1391
1475
  "Tool wishlist event log",
1392
1476
  );
1393
1477
  parsed.events.push(event);
1478
+ onRecorded({ assessmentRecorded: advisory !== undefined });
1394
1479
  }
1395
1480
 
1396
1481
  writeReport(
@@ -1407,6 +1492,7 @@ export async function recordCapabilityGap(options) {
1407
1492
 
1408
1493
  return {
1409
1494
  duplicate,
1495
+ assessmentRecorded: !duplicate && advisory !== undefined,
1410
1496
  regression,
1411
1497
  resolved: priorState === "retired",
1412
1498
  canonicalKey,