specpi 0.26.0 → 0.27.0

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.
@@ -0,0 +1,138 @@
1
+ // "We only send digests" is a promise until someone can check it. This ledger is the check: one
2
+ // line per call recording what was sent, how big it was and what came back, with a SHA-256 of the
3
+ // exact payload and never the payload itself.
4
+ //
5
+ // It doubles as the calibration corpus. Thresholds in this layer are meant to be read off a curve
6
+ // rather than guessed, and the curve has to come from somewhere.
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { createHash } from "node:crypto";
11
+ import { jevDirectory, regularFile } from "./config.mjs";
12
+
13
+ const MAX_LINES = 5000;
14
+ const MAX_LEDGER_BYTES = 8 * 1024 * 1024;
15
+
16
+ export function ledgerPath() {
17
+ return path.join(jevDirectory(), "transmissions.jsonl");
18
+ }
19
+
20
+ export function payloadDigest(payload) {
21
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
22
+ }
23
+
24
+ /**
25
+ * Append one record. Ledger failure never fails the call that produced it: an advisor that cannot
26
+ * write its audit line still must not break the session, so the error is swallowed and the caller
27
+ * proceeds. The absence of a line is itself visible in `/jev ledger`.
28
+ */
29
+ export function record(entry) {
30
+ try {
31
+ const file = ledgerPath();
32
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
33
+ if (fs.existsSync(file)) {
34
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
35
+ if (!stat || !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
36
+ return false;
37
+ }
38
+
39
+ if (stat.size > MAX_LEDGER_BYTES) {
40
+ rotate(file);
41
+ }
42
+ }
43
+
44
+ fs.appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, {
45
+ mode: 0o600,
46
+ });
47
+
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /** Keep the newest half. Unbounded growth is a worse failure than losing old audit lines. */
55
+ function rotate(file) {
56
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
57
+ const kept = lines.slice(-Math.floor(MAX_LINES / 2));
58
+ fs.writeFileSync(file, kept.length > 0 ? `${kept.join("\n")}\n` : "", { mode: 0o600 });
59
+ }
60
+
61
+ /**
62
+ * Roll the ledger up into the few numbers a report wants. Kept here rather than in the eval suite
63
+ * because the shape of a line is this module's business, and a reader that has to know it is a
64
+ * second copy of the schema waiting to drift.
65
+ */
66
+ export function summarize(entries) {
67
+ const lines = Array.isArray(entries) ? entries : [];
68
+ const bySystem = {};
69
+ for (const entry of lines) {
70
+ const name = typeof entry?.system === "string" ? entry.system : "unknown";
71
+ const bucket = (bySystem[name] ??= {
72
+ calls: 0,
73
+ failed: 0,
74
+ applied: 0,
75
+ savedBytes: 0,
76
+ stateBytes: 0,
77
+ outcomes: {},
78
+ });
79
+ bucket.calls += 1;
80
+ bucket.failed += entry?.ok === true ? 0 : 1;
81
+ bucket.applied += entry?.applied === true ? 1 : 0;
82
+ bucket.savedBytes += Number.isFinite(entry?.savedBytes) ? entry.savedBytes : 0;
83
+ bucket.stateBytes += Number.isFinite(entry?.stateBytes) ? entry.stateBytes : 0;
84
+ // "Asked 3 times, applied 0" is a number. "Asked 3 times, applied 0, all three because the
85
+ // result might hold the answer" is a finding.
86
+ const outcome =
87
+ typeof entry?.outcome === "string" ? entry.outcome : entry?.ok === true ? "unrecorded" : "failed";
88
+ bucket.outcomes[outcome] = (bucket.outcomes[outcome] ?? 0) + 1;
89
+ }
90
+
91
+ const totals = Object.values(bySystem);
92
+
93
+ return {
94
+ calls: lines.length,
95
+ failed: totals.reduce((sum, item) => sum + item.failed, 0),
96
+ applied: totals.reduce((sum, item) => sum + item.applied, 0),
97
+ savedBytes: totals.reduce((sum, item) => sum + item.savedBytes, 0),
98
+ stateBytes: totals.reduce((sum, item) => sum + item.stateBytes, 0),
99
+ // Named for what retention does, because it is the only system that shortens anything and
100
+ // the number is meaningless averaged with systems that cannot.
101
+ elisions: bySystem.retention?.applied ?? 0,
102
+ bytesDropped: bySystem.retention?.savedBytes ?? 0,
103
+ bySystem,
104
+ };
105
+ }
106
+
107
+ /** Every line, for a caller that wants to summarize rather than display. `read` is for display. */
108
+ export function readAll() {
109
+ return read(Number.MAX_SAFE_INTEGER);
110
+ }
111
+
112
+ export function read(limit = 20) {
113
+ try {
114
+ const file = ledgerPath();
115
+ if (!regularFile(file, "Jev ledger")) {
116
+ return [];
117
+ }
118
+ } catch {
119
+ // An oversize ledger is still readable; only a link or irregular file is refused.
120
+ }
121
+
122
+ try {
123
+ const lines = fs.readFileSync(ledgerPath(), "utf8").split("\n").filter(Boolean);
124
+
125
+ return lines
126
+ .slice(-Math.max(1, limit))
127
+ .map((line) => {
128
+ try {
129
+ return JSON.parse(line);
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ })
134
+ .filter(Boolean);
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
@@ -0,0 +1,124 @@
1
+ // System 6: decide, once, before the first provider request, whether this session is going to need
2
+ // a withdrawn tool group -- and if so, offer it then rather than in the middle.
3
+ //
4
+ // This is permitted by the standing rule because it is a once-per-session decision made before the
5
+ // first request, which is one of the three cache-safe shapes. It is also distinct from the tool
6
+ // router this project rejected: that rejection was about six tools whose usefulness local state can
7
+ // already determine from wishlist and goal state. "Will this task need a browser" is not in local
8
+ // state, and no amount of inspecting the repository answers it.
9
+ //
10
+ // It exists because Phase 7 measured what the rejection was always assumed to be worth. Flipping
11
+ // Browser QA on at turn 6 of `t3-cascade-ledger` collapsed cached tokens to 3,200 at the next
12
+ // request in three attempts out of three, while the prompt kept climbing, and the re-warm cost 20%
13
+ // of the attempt on average. Arming the same group from the first request cost 16% more than never
14
+ // arming it, against 47% for flipping mid-session. Paying up front is about three times cheaper
15
+ // than paying when the need appears -- so the whole value of this system is moving the decision
16
+ // earlier, and that is all it does.
17
+ //
18
+ // It holds no authority. It activates nothing on its own: it pre-fills the same confirmation the
19
+ // human would have seen from `request_capability`, at turn 0 instead of turn 6. A decline is
20
+ // remembered for the session, and with no interactive human it proposes nothing at all.
21
+
22
+ import { choice, noul } from "../client.mjs";
23
+ import { choiceValue, nounTrue } from "../gate.mjs";
24
+ import { compact } from "../sanitize.mjs";
25
+
26
+ export const TASK_KINDS = Object.freeze({
27
+ code: "Reading or changing source code in this repository",
28
+ web: "Looking something up online, or reading external pages",
29
+ ui: "Checking how a page renders or behaves in a browser",
30
+ ops: "Builds, releases, configuration or tooling",
31
+ docs: "Writing or editing prose",
32
+ other: "Anything else",
33
+ });
34
+
35
+ /**
36
+ * Cheap, local, and checked before anything is sent. The rule is ask local state first, and while
37
+ * local state cannot answer "will this need a browser", it answers "is that question worth asking":
38
+ * a task with no web signal in its wording and a repository with no web assets is not one.
39
+ */
40
+ const PROMPT_SIGNALS =
41
+ /\b(https?:\/\/|www\.|url|link|page|site|website|web|browser|render|screenshot|accessibility|a11y|css|dom|localhost|search online|look .{0,12}up)\b/iu;
42
+
43
+ /** Names that mean this repository has something a browser could open. */
44
+ const WEB_ASSETS = /^(index\.html|.*\.html?|vite\.config\..*|next\.config\..*|svelte\.config\..*|astro\.config\..*)$/iu;
45
+
46
+ export function promptSignal(prompt) {
47
+ return PROMPT_SIGNALS.test(String(prompt ?? ""));
48
+ }
49
+
50
+ /** One bounded readdir of the working directory. Never recursive: this is a hint, not a survey. */
51
+ export function repositorySignal(entries) {
52
+ return (entries ?? []).slice(0, 200).some((name) => WEB_ASSETS.test(String(name)));
53
+ }
54
+
55
+ export function localSignals({ prompt, entries }) {
56
+ const reasons = [];
57
+ if (promptSignal(prompt)) {
58
+ reasons.push("prompt-mentions-web");
59
+ }
60
+
61
+ if (repositorySignal(entries)) {
62
+ reasons.push("repository-has-web-assets");
63
+ }
64
+
65
+ return { ask: reasons.length > 0, reasons };
66
+ }
67
+
68
+ /**
69
+ * The prompt is the one place in this layer where the user's own words are sent rather than a
70
+ * digest of them, and it is bounded hard for that reason. It is the only thing that can answer the
71
+ * question, and it is one message rather than a transcript.
72
+ */
73
+ export function buildInput({ prompt, reasons, available, cwdEntries = [] }) {
74
+ return {
75
+ request: compact(prompt ?? "", 400),
76
+ reasons,
77
+ withdrawnGroups: available,
78
+ workspaceFiles: cwdEntries.slice(0, 12).map((name) => compact(name, 40)),
79
+ };
80
+ }
81
+
82
+ export function questions({ available = [] } = {}) {
83
+ const asked = { task_kind: choice("What kind of work is this request asking for?", TASK_KINDS) };
84
+ if (available.includes("web")) {
85
+ asked.needs_web = noul("Completing this request will require searching the web or fetching an external page");
86
+ }
87
+
88
+ if (available.includes("browser")) {
89
+ asked.needs_browser = noul(
90
+ "Completing this request will require opening a page in a browser to check how it renders or behaves",
91
+ );
92
+ }
93
+
94
+ // Asked whenever the session is capable of it, because the answer is a suggestion to the human
95
+ // rather than an activation: delegation binds a model and a host and needs its own command.
96
+ asked.needs_delegation = noul(
97
+ "This request would be better answered by reading a large set of files than by reasoning about a few",
98
+ );
99
+
100
+ return asked;
101
+ }
102
+
103
+ /**
104
+ * Deliberately asymmetric. A false positive costs the group's schema on every request for the rest
105
+ * of the session and a confirmation the human did not need; a false negative costs nothing at all,
106
+ * because it leaves today's behaviour exactly as it is and `request_capability` is still there. So
107
+ * this proposes only on a high bar, and the `capability` thresholds are the strictest in the layer.
108
+ */
109
+ export function decide(answers, available = []) {
110
+ const propose = [];
111
+ if (available.includes("web") && nounTrue(answers?.needs_web, "capability")) {
112
+ propose.push("web");
113
+ }
114
+
115
+ if (available.includes("browser") && nounTrue(answers?.needs_browser, "capability")) {
116
+ propose.push("browser");
117
+ }
118
+
119
+ return {
120
+ propose,
121
+ taskKind: choiceValue(answers?.task_kind, "capability"),
122
+ suggestDelegation: nounTrue(answers?.needs_delegation, "capability"),
123
+ };
124
+ }
@@ -0,0 +1,153 @@
1
+ // System 1b: guide compaction, which is the one moment the prompt cache is discarded anyway.
2
+ //
3
+ // Pi's `findCutPoint` is documented as "walk backwards from newest, accumulating estimated message
4
+ // sizes, stop when we've accumulated >= keepRecentTokens". It is a token ruler: it cannot tell the
5
+ // load-bearing finding from six dead-end greps, and it discards whichever falls on the wrong side
6
+ // of the line. Since the prefix is being rebuilt regardless, improving that choice costs nothing.
7
+ //
8
+ // This system never sets the cut itself. It supplies `customInstructions` — an existing documented
9
+ // parameter on the compaction path — so the summariser is told what this session was actually
10
+ // about. The token budget still bounds the result, so bad advice can shape a summary, never blow
11
+ // the budget or drop an entry the preparation meant to keep.
12
+
13
+ import { choice, noul } from "../client.mjs";
14
+ import { choiceValue, nounTrue } from "../gate.mjs";
15
+ import { compact } from "../sanitize.mjs";
16
+
17
+ export const WORK_KINDS = Object.freeze({
18
+ debugging: "Tracking down why something fails",
19
+ building: "Adding or changing a feature",
20
+ refactoring: "Restructuring code without changing behaviour",
21
+ research: "Reading and answering questions about a codebase",
22
+ testing: "Writing or repairing tests",
23
+ ops: "Builds, releases, configuration or tooling",
24
+ review: "Reading a diff and judging it",
25
+ });
26
+
27
+ /**
28
+ * A digest of what compaction is about to discard: entry kinds and scale, never their text. The
29
+ * summariser still sees the real conversation; this only steers what it keeps.
30
+ */
31
+ export function buildInput({ preparation, objective }) {
32
+ const messages = preparation?.messagesToSummarize ?? [];
33
+ const kinds = {};
34
+ for (const message of messages) {
35
+ const role = typeof message?.role === "string" ? message.role : "unknown";
36
+ kinds[role] = (kinds[role] ?? 0) + 1;
37
+ }
38
+
39
+ const files = preparation?.fileOps ?? {};
40
+
41
+ return {
42
+ objective: compact(objective ?? "", 180),
43
+ discarding: messages.length,
44
+ roles: kinds,
45
+ tokensBefore: preparation?.tokensBefore ?? 0,
46
+ splitTurn: preparation?.isSplitTurn === true,
47
+ filesRead: [...(files.read ?? [])].slice(0, 12).map((item) => compact(item, 60)),
48
+ filesWritten: [...(files.written ?? []), ...(files.edited ?? [])].slice(0, 12).map((item) => compact(item, 60)),
49
+ hadPreviousSummary: typeof preparation?.previousSummary === "string",
50
+ };
51
+ }
52
+
53
+ /**
54
+ * The same digest for a branch being left behind. `/tree` hands a different preparation shape --
55
+ * session entries rather than messages, and no token count, because nothing is being cut to fit a
56
+ * budget -- so it gets its own builder rather than a compaction input with three fields quietly
57
+ * reading undefined.
58
+ */
59
+ export function buildBranchInput({ preparation, objective }) {
60
+ const entries = preparation?.entriesToSummarize ?? [];
61
+ const kinds = {};
62
+ for (const entry of entries) {
63
+ const kind = typeof entry?.type === "string" ? entry.type : "unknown";
64
+ kinds[kind] = (kinds[kind] ?? 0) + 1;
65
+ }
66
+
67
+ return {
68
+ objective: compact(objective ?? "", 180),
69
+ abandoning: entries.length,
70
+ kinds,
71
+ wantsSummary: preparation?.userWantsSummary === true,
72
+ // Navigating to an ancestor is backing out of a line of work; navigating elsewhere is
73
+ // moving between siblings. The distinction is most of what a label has to capture.
74
+ toAncestor: preparation?.targetId === preparation?.commonAncestorId,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Short, navigational, and a fixed enum so no model-written text reaches the session file. `/tree`
80
+ * can filter to labelled entries, so a branch that says what it was is the difference between a
81
+ * navigable tree and a list of timestamps.
82
+ */
83
+ export const BRANCH_LABELS = Object.freeze({
84
+ "dead end": "The branch was abandoned because the approach did not work",
85
+ "alternative tried": "A different approach to the same goal, set aside for another",
86
+ "work completed": "The branch finished what it set out to do",
87
+ research: "The branch was reading and answering questions, not changing anything",
88
+ reverted: "The branch's changes were undone",
89
+ interrupted: "The branch stopped part-way for an unrelated reason",
90
+ });
91
+
92
+ export function questions({ branch = false } = {}) {
93
+ return {
94
+ ...(branch ? { branch_label: choice("What was this abandoned branch?", BRANCH_LABELS) } : {}),
95
+ work_kind: choice("What kind of work has this session mostly been doing?", WORK_KINDS),
96
+ unresolved_thread: noul("There is an unfinished investigation whose findings must survive compaction"),
97
+ discarded_span_was_dead_ends: noul(
98
+ "The work being discarded was mostly abandoned attempts that led nowhere useful",
99
+ ),
100
+ };
101
+ }
102
+
103
+ const FOCUS = Object.freeze({
104
+ debugging: "the symptom, what has been ruled out, and the current hypothesis",
105
+ building: "what has been implemented so far and what remains",
106
+ refactoring: "the invariants being preserved and which call sites have been updated",
107
+ research: "the questions answered so far, with the files each answer came from",
108
+ testing: "which tests exist, which fail, and why",
109
+ ops: "the commands run, their outcomes, and the current configuration state",
110
+ review: "the findings raised so far and their severity",
111
+ });
112
+
113
+ /**
114
+ * Build `customInstructions` from gated answers only. With nothing gated this returns undefined and
115
+ * Pi's own default prompt is used unchanged.
116
+ */
117
+ /**
118
+ * The label for a branch summary entry, or undefined when the answer is ungated. Separate from
119
+ * `decide` because the compaction hook has no label to set and would carry a dead field.
120
+ */
121
+ export function label(answers) {
122
+ const value = choiceValue(answers?.branch_label, "compaction");
123
+
124
+ return value && Object.hasOwn(BRANCH_LABELS, value) ? value : undefined;
125
+ }
126
+
127
+ export function decide(answers) {
128
+ const kind = choiceValue(answers?.work_kind, "compaction");
129
+ const unresolved = nounTrue(answers?.unresolved_thread, "compaction");
130
+ const deadEnds = nounTrue(answers?.discarded_span_was_dead_ends, "compaction");
131
+ const parts = [];
132
+ if (kind && FOCUS[kind]) {
133
+ parts.push(`This session has mainly been ${kind}. Prioritise ${FOCUS[kind]}.`);
134
+ }
135
+
136
+ if (unresolved) {
137
+ parts.push(
138
+ "An investigation is still open. Preserve its findings and the current hypothesis in full, even at the cost of earlier detail.",
139
+ );
140
+ }
141
+
142
+ if (deadEnds) {
143
+ parts.push(
144
+ "Most of the discarded work was abandoned attempts. Record what was ruled out in one line each rather than recounting them, so the same paths are not retried.",
145
+ );
146
+ }
147
+
148
+ if (parts.length === 0) {
149
+ return { customInstructions: undefined, deadEnds, unresolved };
150
+ }
151
+
152
+ return { customInstructions: parts.join(" "), deadEnds, unresolved };
153
+ }
@@ -0,0 +1,140 @@
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
+
19
+ import { choice, noul, score } from "../client.mjs";
20
+ import { choiceValue, nounTrue, scoreLevel } from "../gate.mjs";
21
+ import { compact } from "../sanitize.mjs";
22
+
23
+ export const IMPACT_LEVELS = Object.freeze([
24
+ "Minor: a small inconvenience with an easy workaround",
25
+ "Moderate: real friction that cost time or forced a detour",
26
+ "Blocked: the task could not be completed as asked",
27
+ ]);
28
+
29
+ export const FIX_KINDS = Object.freeze({
30
+ tool: "A new or changed tool would solve it",
31
+ skill: "A skill or documented procedure would solve it",
32
+ prompt: "Different instructions would solve it",
33
+ config: "A configuration or setting change would solve it",
34
+ bug: "Something is broken and should be repaired",
35
+ unknown: "Not clear from what was observed",
36
+ });
37
+
38
+ 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;
42
+
43
+ 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);
68
+ }
69
+
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);
85
+
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
+ }
91
+
92
+ return criteria;
93
+ }
94
+
95
+ export function buildInput({ gap, existing = [] }) {
96
+ return {
97
+ capability: compact(gap?.capability ?? "", 120),
98
+ scenario: compact(gap?.scenario ?? "", 200),
99
+ 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)),
103
+ };
104
+ }
105
+
106
+ export function questions({ gap, existing = [] }) {
107
+ return {
108
+ cluster: choice(
109
+ "Which known problem is this the same underlying problem as, if any?",
110
+ clusterOptions(existing, gap),
111
+ ),
112
+ independent_impact: score("How badly did this actually obstruct the task?", IMPACT_LEVELS),
113
+ suggested_fix: choice("What kind of change would address this?", FIX_KINDS),
114
+ contains_secret_or_path: noul(
115
+ "This report contains a credential, an absolute filesystem path, or other machine-specific detail",
116
+ ),
117
+ is_transient_or_user_error: noul(
118
+ "This was a one-off failure or a mistake in how the task was asked, not a reusable gap in the harness",
119
+ ),
120
+ };
121
+ }
122
+
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) {
130
+ const cluster = choiceValue(answers?.cluster, "gap");
131
+ const level = scoreLevel(answers?.independent_impact, "gap");
132
+
133
+ return {
134
+ canonicalKey: cluster && cluster !== NEW_CLUSTER ? cluster : undefined,
135
+ independentImpact: level === undefined ? undefined : IMPACT_NAMES[level],
136
+ suggestedFix: choiceValue(answers?.suggested_fix, "gap"),
137
+ blockForSanitization: nounTrue(answers?.contains_secret_or_path, "gap"),
138
+ transient: nounTrue(answers?.is_transient_or_user_error, "gap"),
139
+ };
140
+ }