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.
- package/CHANGELOG.md +14 -0
- package/NPM_RELEASE.md +2 -2
- package/README.md +4 -5
- package/SECURITY_MODEL.md +10 -6
- package/extensions/jev-advisor/broker.mjs +87 -8
- package/extensions/jev-advisor/client.mjs +33 -10
- package/extensions/jev-advisor/consent.mjs +19 -7
- package/extensions/jev-advisor/gate.mjs +20 -5
- package/extensions/jev-advisor/index.ts +307 -189
- package/extensions/jev-advisor/ledger.mjs +16 -2
- package/extensions/jev-advisor/questions/gap.mjs +49 -86
- package/extensions/jev-advisor/questions/retention.mjs +5 -8
- package/extensions/jev-advisor/questions/sources.mjs +27 -56
- package/extensions/jev-advisor/sanitize.mjs +0 -0
- package/extensions/tool-wishlist/core.mjs +86 -0
- package/extensions/tool-wishlist/index.ts +81 -8
- package/extensions/workflow-controls/index.ts +23 -0
- package/package.json +2 -2
|
@@ -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
|
|
115
|
+
elisions,
|
|
102
116
|
bytesDropped: bySystem.retention?.savedBytes ?? 0,
|
|
103
117
|
bySystem,
|
|
104
118
|
};
|
|
@@ -1,21 +1,5 @@
|
|
|
1
|
-
//
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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 ?? "",
|
|
62
|
+
scenario: compact(gap?.scenario ?? "", 180),
|
|
99
63
|
limitation: compact(gap?.limitation ?? "", 160),
|
|
100
|
-
workaround: compact(gap?.workaround ?? "",
|
|
101
|
-
claimedImpact: compact(gap?.impact ?? "",
|
|
102
|
-
knownProblems: existing.
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
suggestedFix:
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
20
|
-
export function buildInput({ question, candidates }) {
|
|
14
|
+
export function buildInput({ question, mode, candidates }) {
|
|
21
15
|
return {
|
|
22
|
-
question: compact(question ?? "",
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
"
|
|
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 (
|
|
32
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
45
33
|
asked[`source_${index}`] = score(
|
|
46
|
-
`How likely is ${
|
|
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.
|
|
60
|
-
|
|
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.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
82
|
-
unrelated:
|
|
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,
|