blun-king-cli 9.1.486 → 9.1.489
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/LIESMICH.txt +28 -7
- package/README.md +16 -3
- package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -0
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -0
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -0
- package/agent-spine-plugin/.mcp.json +8 -0
- package/agent-spine-plugin/CHANGELOG.md +277 -0
- package/agent-spine-plugin/LICENSE +186 -0
- package/agent-spine-plugin/README.md +342 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -0
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -0
- package/agent-spine-plugin/bin/agentspine.js +7 -0
- package/agent-spine-plugin/blun.plugin.json +33 -0
- package/agent-spine-plugin/docs/acceptance.md +61 -0
- package/agent-spine-plugin/docs/architecture.md +183 -0
- package/agent-spine-plugin/docs/attention.md +121 -0
- package/agent-spine-plugin/docs/automatic-continuity.md +79 -0
- package/agent-spine-plugin/docs/channel-runtime.md +92 -0
- package/agent-spine-plugin/docs/coordination.md +138 -0
- package/agent-spine-plugin/docs/feed-transport.md +99 -0
- package/agent-spine-plugin/docs/gateway-runtime.md +109 -0
- package/agent-spine-plugin/docs/harness-reference.md +45 -0
- package/agent-spine-plugin/docs/host-integration.md +129 -0
- package/agent-spine-plugin/docs/https-transport.md +116 -0
- package/agent-spine-plugin/docs/learning.md +98 -0
- package/agent-spine-plugin/docs/object-transport.md +93 -0
- package/agent-spine-plugin/docs/peer-transport.md +88 -0
- package/agent-spine-plugin/docs/preservation-contract.md +53 -0
- package/agent-spine-plugin/docs/quality-gates.md +50 -0
- package/agent-spine-plugin/docs/relationships.md +67 -0
- package/agent-spine-plugin/docs/releasing.md +83 -0
- package/agent-spine-plugin/docs/roadmap.md +307 -0
- package/agent-spine-plugin/docs/selfstarter.md +88 -0
- package/agent-spine-plugin/docs/session-briefing.md +74 -0
- package/agent-spine-plugin/docs/shared-memory.md +259 -0
- package/agent-spine-plugin/docs/source-roots.md +86 -0
- package/agent-spine-plugin/docs/sqlite-transport.md +76 -0
- package/agent-spine-plugin/hooks/hooks.json +95 -0
- package/agent-spine-plugin/hooks/version.json +5 -0
- package/agent-spine-plugin/package.json +69 -0
- package/agent-spine-plugin/scripts/check-hosts.js +193 -0
- package/agent-spine-plugin/scripts/check-install.js +551 -0
- package/agent-spine-plugin/scripts/check-syntax.js +29 -0
- package/agent-spine-plugin/scripts/github-actions.js +11 -0
- package/agent-spine-plugin/scripts/release-check.js +127 -0
- package/agent-spine-plugin/scripts/run-acceptance.js +19 -0
- package/agent-spine-plugin/scripts/run-checks.js +46 -0
- package/agent-spine-plugin/scripts/run-tests-hermetic.js +73 -0
- package/agent-spine-plugin/skill/SKILL.md +76 -0
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +84 -0
- package/agent-spine-plugin/spine-example/1-identity.md +12 -0
- package/agent-spine-plugin/spine-example/2-voice.md +6 -0
- package/agent-spine-plugin/spine-example/3-conduct.md +8 -0
- package/agent-spine-plugin/spine-example/4-history.md +4 -0
- package/agent-spine-plugin/src/cli.js +950 -0
- package/agent-spine-plugin/src/hook.js +646 -0
- package/agent-spine-plugin/src/index.js +84 -0
- package/agent-spine-plugin/src/lib/acceptance.js +294 -0
- package/agent-spine-plugin/src/lib/attention.js +755 -0
- package/agent-spine-plugin/src/lib/audit.js +308 -0
- package/agent-spine-plugin/src/lib/authentication.js +513 -0
- package/agent-spine-plugin/src/lib/briefing.js +305 -0
- package/agent-spine-plugin/src/lib/catalog.js +142 -0
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -0
- package/agent-spine-plugin/src/lib/context.js +154 -0
- package/agent-spine-plugin/src/lib/continuity.js +333 -0
- package/agent-spine-plugin/src/lib/coordination.js +577 -0
- package/agent-spine-plugin/src/lib/documents.js +199 -0
- package/agent-spine-plugin/src/lib/feed-transport.js +497 -0
- package/agent-spine-plugin/src/lib/filesystem-retry.js +27 -0
- package/agent-spine-plugin/src/lib/gateway-runtime.js +760 -0
- package/agent-spine-plugin/src/lib/graph.js +284 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -0
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -0
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -0
- package/agent-spine-plugin/src/lib/learning.js +645 -0
- package/agent-spine-plugin/src/lib/object-transport.js +206 -0
- package/agent-spine-plugin/src/lib/paths.js +60 -0
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -0
- package/agent-spine-plugin/src/lib/persona-runtime.js +503 -0
- package/agent-spine-plugin/src/lib/runtime.js +13 -0
- package/agent-spine-plugin/src/lib/selfstarter.js +819 -0
- package/agent-spine-plugin/src/lib/sharing.js +969 -0
- package/agent-spine-plugin/src/lib/source-roots.js +451 -0
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -0
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -0
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -0
- package/agent-spine-plugin/src/mcp.js +526 -0
- package/agent-spine-plugin/src/version.js +1 -0
- package/agent-spine-plugin/src/worker.js +178 -0
- package/bin/identity-context-policy.cjs +51 -1
- package/bin/launcher-runtime.js +42 -1
- package/bin/plugin-bootstrap.js +68 -27
- package/bin/repeated-user-message-projection.cjs +6 -0
- package/bin/turn-thinking-policy.cjs +5 -4
- package/bin/turn-tool-performance-policy.cjs +16 -2
- package/blun.mjs +187 -35
- package/package.json +3 -1
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { buildCatalog } from "./catalog.js";
|
|
5
|
+
import { isFileLockContention } from "./filesystem-retry.js";
|
|
6
|
+
import { loadGraph } from "./graph.js";
|
|
7
|
+
import { projectStateDir } from "./paths.js";
|
|
8
|
+
|
|
9
|
+
const KINDS = new Set(["preference", "no-go", "goal", "correction", "personal-fact", "project-fact", "reference"]);
|
|
10
|
+
const EVIDENCE_TYPES = new Set(["user-statement", "document", "interaction", "test"]);
|
|
11
|
+
const PRIVACY = new Set(["private", "shared", "group"]);
|
|
12
|
+
const STATUSES = new Set(["candidate", "accepted", "rejected", "superseded", "rolled-back"]);
|
|
13
|
+
const AUTO_KINDS = new Set(["project-fact", "reference"]);
|
|
14
|
+
const CONTINUITY_AUTO_KINDS = new Set(["preference", "no-go", "correction", "project-fact", "reference"]);
|
|
15
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
16
|
+
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
17
|
+
const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/i;
|
|
18
|
+
const AUTHORITY_ASSERTION_RE = /\b(?:user|agent|person|they|he|she|i|ich|wir|nutzer|benutzer).{0,60}\b(?:may|can|is allowed|is authorized|has|have|darf|berechtigt|hat|haben).{0,50}\b(?:admin(?:istrator)?|permissions?|rights?|authorization|production access|deploy|billing|spending|policy exception|bypass|zugang|rechte|berechtigung|produktion|abrechnung|ausnahme|umgehen)\b/i;
|
|
19
|
+
|
|
20
|
+
function defaults() {
|
|
21
|
+
return { autoPromote: false, minConfidence: 0.85, minEvidence: 2, maxContextItems: 12 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function emptyLearning(root) {
|
|
25
|
+
return {
|
|
26
|
+
schema: "agentspine.learning/v1",
|
|
27
|
+
root,
|
|
28
|
+
config: defaults(),
|
|
29
|
+
candidates: [],
|
|
30
|
+
history: []
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeState(value, root) {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
36
|
+
|| value.schema !== "agentspine.learning/v1" || value.root !== root
|
|
37
|
+
|| !value.config || typeof value.config !== "object" || Array.isArray(value.config)
|
|
38
|
+
|| !Array.isArray(value.candidates) || !value.candidates.every((item) => item && typeof item === "object" && Array.isArray(item.evidence))
|
|
39
|
+
|| !Array.isArray(value.history) || !value.history.every((item) => item && typeof item === "object")) {
|
|
40
|
+
throw new Error("learning state structure is invalid; run the audit before learning");
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validConfig(config) {
|
|
46
|
+
return typeof config?.autoPromote === "boolean"
|
|
47
|
+
&& Number.isFinite(config.minConfidence) && config.minConfidence >= 0.5 && config.minConfidence <= 1
|
|
48
|
+
&& Number.isInteger(config.minEvidence) && config.minEvidence >= 1 && config.minEvidence <= 10
|
|
49
|
+
&& Number.isInteger(config.maxContextItems) && config.maxContextItems >= 1 && config.maxContextItems <= 50;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function date(value, field = "date") {
|
|
53
|
+
const parsed = value instanceof Date ? value : new Date(value);
|
|
54
|
+
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
55
|
+
return parsed.toISOString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function number(value, field, minimum, maximum) {
|
|
59
|
+
const parsed = Number(value);
|
|
60
|
+
if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) {
|
|
61
|
+
throw new Error(`${field} must be between ${minimum} and ${maximum}`);
|
|
62
|
+
}
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function integer(value, field, minimum, maximum) {
|
|
67
|
+
const parsed = number(value, field, minimum, maximum);
|
|
68
|
+
if (!Number.isInteger(parsed)) throw new Error(`${field} must be an integer`);
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function relativePath(value, field) {
|
|
73
|
+
if (value === null || value === undefined || value === "") return null;
|
|
74
|
+
if (typeof value !== "string") throw new Error(`${field} must be a project-relative path`);
|
|
75
|
+
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
76
|
+
if (normalized.startsWith("/") || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
77
|
+
throw new Error(`${field} must be a project-relative path`);
|
|
78
|
+
}
|
|
79
|
+
return normalized;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safeText(value, field, maximum) {
|
|
83
|
+
if (!value || typeof value !== "string") throw new Error(`${field} is required`);
|
|
84
|
+
const text = value.trim().slice(0, maximum);
|
|
85
|
+
if (SECRET_RE.test(text)) throw new Error(`${field} appears to contain a secret and cannot enter learning state`);
|
|
86
|
+
return text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function assertSafeClaim(claim) {
|
|
90
|
+
if (AUTHORITY_ASSERTION_RE.test(claim)) {
|
|
91
|
+
throw new Error("authority and access claims cannot become learned context");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isGroupMember(graph, groupId, entityId) {
|
|
96
|
+
if (!entityId || entityId === groupId) return true;
|
|
97
|
+
return graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private" && (
|
|
98
|
+
(edge.from === entityId && edge.to === groupId) || (edge.to === entityId && edge.from === groupId)
|
|
99
|
+
));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateScope(privacy, groupId, graph, subjectId) {
|
|
103
|
+
if (!PRIVACY.has(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
104
|
+
if (subjectId !== null && !graph.entities.some((entity) => entity.id === subjectId)) throw new Error(`unknown subject entity: ${subjectId}`);
|
|
105
|
+
if (privacy === "group") {
|
|
106
|
+
if (!groupId) throw new Error("group privacy requires groupId");
|
|
107
|
+
const group = graph.entities.find((entity) => entity.id === groupId);
|
|
108
|
+
if (!group || group.kind !== "group") throw new Error(`unknown group entity: ${groupId}`);
|
|
109
|
+
if (!isGroupMember(graph, groupId, subjectId)) throw new Error(`subject is not a visible member of group: ${groupId}`);
|
|
110
|
+
} else if (groupId !== null && groupId !== undefined) {
|
|
111
|
+
throw new Error("groupId is only valid with group privacy");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readState(path, root) {
|
|
116
|
+
try {
|
|
117
|
+
const metadata = await stat(path);
|
|
118
|
+
if (metadata.size > MAX_STATE_BYTES) throw new Error("learning state exceeds the 5 MiB read limit");
|
|
119
|
+
return normalizeState(JSON.parse(await readFile(path, "utf8")), root);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error.code !== "ENOENT") throw error;
|
|
122
|
+
return emptyLearning(root);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function loadLearning(root = process.cwd(), providedCatalog = null) {
|
|
127
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
128
|
+
const directory = await projectStateDir(catalog.root);
|
|
129
|
+
const learningPath = join(directory, "learning.json");
|
|
130
|
+
return { learning: await readState(learningPath, catalog.root), learningPath, catalog };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function inspectLearning(root = process.cwd(), providedCatalog = null) {
|
|
134
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
135
|
+
const directory = await projectStateDir(catalog.root);
|
|
136
|
+
const learningPath = join(directory, "learning.json");
|
|
137
|
+
try {
|
|
138
|
+
return { learning: await readState(learningPath, catalog.root), learningPath, catalog, error: null };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return { learning: emptyLearning(catalog.root), learningPath, catalog, error: error.message };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function saveState(state, path) {
|
|
145
|
+
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
146
|
+
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("learning state exceeds 5 MiB; reject or delete old candidates first");
|
|
147
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
148
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
149
|
+
try {
|
|
150
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
151
|
+
try {
|
|
152
|
+
await rename(temporary, path);
|
|
153
|
+
break;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const transientWindowsReplace = process.platform === "win32"
|
|
156
|
+
&& ["EACCES", "EBUSY", "EPERM"].includes(error.code);
|
|
157
|
+
if (!transientWindowsReplace || attempt >= 7) throw error;
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await unlink(temporary).catch((cleanupError) => {
|
|
163
|
+
if (cleanupError.code !== "ENOENT") error.cleanupError = cleanupError;
|
|
164
|
+
});
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function withLock(path, root, task) {
|
|
170
|
+
const lockPath = `${path}.lock`;
|
|
171
|
+
let handle;
|
|
172
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
173
|
+
try {
|
|
174
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
175
|
+
break;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!isFileLockContention(error)) throw error;
|
|
178
|
+
try {
|
|
179
|
+
const metadata = await stat(lockPath);
|
|
180
|
+
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
|
|
181
|
+
} catch (lockError) {
|
|
182
|
+
if (lockError.code !== "ENOENT") throw lockError;
|
|
183
|
+
}
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!handle) throw new Error("learning state is busy; retry shortly");
|
|
188
|
+
try {
|
|
189
|
+
const state = await readState(path, root);
|
|
190
|
+
if (!validConfig(state.config)) throw new Error("learning configuration is invalid; run the audit before learning");
|
|
191
|
+
const result = await task(state);
|
|
192
|
+
await saveState(state, path);
|
|
193
|
+
return result;
|
|
194
|
+
} finally {
|
|
195
|
+
await handle.close();
|
|
196
|
+
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function mutation(root, operation) {
|
|
201
|
+
const catalog = await buildCatalog(root);
|
|
202
|
+
const { learningPath } = await loadLearning(catalog.root, catalog);
|
|
203
|
+
return withLock(learningPath, catalog.root, async (state) => operation(state, catalog, learningPath));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function preserve(state, kind, value, now) {
|
|
207
|
+
if (!value) return;
|
|
208
|
+
state.history.push({
|
|
209
|
+
kind,
|
|
210
|
+
recordId: value.id || "config",
|
|
211
|
+
subjectId: value.subjectId || null,
|
|
212
|
+
supersededAt: now,
|
|
213
|
+
privacy: value.privacy || "private",
|
|
214
|
+
value: { ...value, authority: "context-only" },
|
|
215
|
+
authority: "context-only"
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeEvidence(input, catalog, now) {
|
|
220
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("evidence is required");
|
|
221
|
+
const id = input.id || `evidence:${randomUUID()}`;
|
|
222
|
+
if (!ID_RE.test(id)) throw new Error("evidence.id must be a stable, whitespace-free identifier");
|
|
223
|
+
const type = input.type || "interaction";
|
|
224
|
+
if (!EVIDENCE_TYPES.has(type)) throw new Error(`unsupported evidence type: ${type}`);
|
|
225
|
+
const sourceDocument = relativePath(input.sourceDocument, "evidence.sourceDocument");
|
|
226
|
+
let sourceSha256 = null;
|
|
227
|
+
if (sourceDocument !== null) {
|
|
228
|
+
const source = catalog.documents.find((document) => document.relativePath === sourceDocument);
|
|
229
|
+
if (!source) throw new Error(`unknown evidence source document: ${sourceDocument}`);
|
|
230
|
+
sourceSha256 = source.sha256;
|
|
231
|
+
}
|
|
232
|
+
if (type === "document" && !sourceDocument) throw new Error("document evidence requires sourceDocument");
|
|
233
|
+
return {
|
|
234
|
+
id,
|
|
235
|
+
type,
|
|
236
|
+
summary: safeText(input.summary, "evidence.summary", 500),
|
|
237
|
+
sourceDocument,
|
|
238
|
+
sourceSha256,
|
|
239
|
+
confidence: number(input.confidence ?? 0.5, "evidence.confidence", 0, 1),
|
|
240
|
+
observedAt: date(input.observedAt || now, "evidence.observedAt"),
|
|
241
|
+
authority: "context-only"
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function evidenceConfidence(evidence) {
|
|
246
|
+
if (!evidence.length) return 0;
|
|
247
|
+
return evidence.reduce((sum, item) => sum + item.confidence, 0) / evidence.length;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function proposeLearning({
|
|
251
|
+
root = process.cwd(), id = `learning:${randomUUID()}`, kind, claim, subjectId = null,
|
|
252
|
+
privacy = "private", groupId = null, evidence, supersedesId = null, now = new Date()
|
|
253
|
+
}) {
|
|
254
|
+
if (!ID_RE.test(id)) throw new Error("id must be a stable, whitespace-free identifier");
|
|
255
|
+
if (!KINDS.has(kind)) throw new Error(`unsupported learning kind: ${kind}`);
|
|
256
|
+
claim = safeText(claim, "claim", 1000);
|
|
257
|
+
assertSafeClaim(claim);
|
|
258
|
+
const timestamp = date(now, "now");
|
|
259
|
+
return mutation(root, async (state, catalog, learningPath) => {
|
|
260
|
+
if (state.candidates.some((candidate) => candidate.id === id)) {
|
|
261
|
+
throw new Error("learning candidate IDs are immutable; add evidence or propose a superseding candidate");
|
|
262
|
+
}
|
|
263
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
264
|
+
validateScope(privacy, groupId, graph, subjectId);
|
|
265
|
+
const normalizedEvidence = normalizeEvidence(evidence, catalog, timestamp);
|
|
266
|
+
const superseded = supersedesId ? state.candidates.find((candidate) => candidate.id === supersedesId) : null;
|
|
267
|
+
if (supersedesId && (!superseded || superseded.status !== "accepted")) {
|
|
268
|
+
throw new Error(`supersedesId must reference an accepted learning: ${supersedesId}`);
|
|
269
|
+
}
|
|
270
|
+
if (superseded && (superseded.kind !== kind || superseded.subjectId !== subjectId || superseded.privacy !== privacy || superseded.groupId !== groupId)) {
|
|
271
|
+
throw new Error("a superseding candidate must keep kind, subject, and privacy scope");
|
|
272
|
+
}
|
|
273
|
+
const candidate = {
|
|
274
|
+
id,
|
|
275
|
+
kind,
|
|
276
|
+
claim,
|
|
277
|
+
subjectId,
|
|
278
|
+
privacy,
|
|
279
|
+
groupId,
|
|
280
|
+
status: "candidate",
|
|
281
|
+
evidence: [normalizedEvidence],
|
|
282
|
+
confidence: normalizedEvidence.confidence,
|
|
283
|
+
supersedesId,
|
|
284
|
+
supersededIds: [],
|
|
285
|
+
automatic: false,
|
|
286
|
+
createdAt: timestamp,
|
|
287
|
+
updatedAt: timestamp,
|
|
288
|
+
acceptedAt: null,
|
|
289
|
+
authority: "context-only"
|
|
290
|
+
};
|
|
291
|
+
state.candidates.push(candidate);
|
|
292
|
+
state.candidates.sort((a, b) => a.id.localeCompare(b.id));
|
|
293
|
+
return { candidate, learningPath };
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function addLearningEvidence({ root = process.cwd(), id, evidence, now = new Date() }) {
|
|
298
|
+
if (!ID_RE.test(id || "")) throw new Error("id is required");
|
|
299
|
+
const timestamp = date(now, "now");
|
|
300
|
+
return mutation(root, (state, catalog, learningPath) => {
|
|
301
|
+
const previous = state.candidates.find((candidate) => candidate.id === id);
|
|
302
|
+
if (!previous) throw new Error(`unknown learning candidate: ${id}`);
|
|
303
|
+
if (previous.status !== "candidate") throw new Error("evidence can only be added to an unreviewed candidate");
|
|
304
|
+
const item = normalizeEvidence(evidence, catalog, timestamp);
|
|
305
|
+
if (previous.evidence.some((entry) => entry.id === item.id)) throw new Error(`duplicate evidence id: ${item.id}`);
|
|
306
|
+
preserve(state, "learning-candidate", previous, timestamp);
|
|
307
|
+
const candidate = {
|
|
308
|
+
...previous,
|
|
309
|
+
evidence: [...previous.evidence, item],
|
|
310
|
+
confidence: evidenceConfidence([...previous.evidence, item]),
|
|
311
|
+
updatedAt: timestamp,
|
|
312
|
+
authority: "context-only"
|
|
313
|
+
};
|
|
314
|
+
state.candidates = state.candidates.map((entry) => entry.id === id ? candidate : entry);
|
|
315
|
+
return { candidate, learningPath };
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function acceptCandidate(state, candidate, timestamp, automatic, promotion = null) {
|
|
320
|
+
preserve(state, "learning-candidate", candidate, timestamp);
|
|
321
|
+
const superseded = candidate.supersedesId
|
|
322
|
+
? state.candidates.find((entry) => entry.id === candidate.supersedesId && entry.status === "accepted")
|
|
323
|
+
: null;
|
|
324
|
+
if (candidate.supersedesId && !superseded) throw new Error("the learning being superseded is no longer active");
|
|
325
|
+
if (superseded) {
|
|
326
|
+
preserve(state, "learning-candidate", superseded, timestamp);
|
|
327
|
+
state.candidates = state.candidates.map((entry) => entry.id === superseded.id
|
|
328
|
+
? { ...entry, status: "superseded", updatedAt: timestamp, authority: "context-only" }
|
|
329
|
+
: entry);
|
|
330
|
+
}
|
|
331
|
+
const accepted = {
|
|
332
|
+
...candidate,
|
|
333
|
+
status: "accepted",
|
|
334
|
+
supersededIds: superseded ? [superseded.id] : [],
|
|
335
|
+
automatic,
|
|
336
|
+
promotion,
|
|
337
|
+
acceptedAt: timestamp,
|
|
338
|
+
updatedAt: timestamp,
|
|
339
|
+
authority: "context-only"
|
|
340
|
+
};
|
|
341
|
+
state.candidates = state.candidates.map((entry) => entry.id === candidate.id ? accepted : entry);
|
|
342
|
+
return accepted;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function reviewLearning({
|
|
346
|
+
root = process.cwd(), id, decision, reason, confirmedByUser = false, now = new Date()
|
|
347
|
+
}) {
|
|
348
|
+
if (!ID_RE.test(id || "")) throw new Error("id is required");
|
|
349
|
+
if (!new Set(["accept", "reject"]).has(decision)) throw new Error("decision must be accept or reject");
|
|
350
|
+
const reviewReason = safeText(reason, "reason", 500);
|
|
351
|
+
const timestamp = date(now, "now");
|
|
352
|
+
return mutation(root, (state, _catalog, learningPath) => {
|
|
353
|
+
const candidate = state.candidates.find((entry) => entry.id === id);
|
|
354
|
+
if (!candidate) throw new Error(`unknown learning candidate: ${id}`);
|
|
355
|
+
if (candidate.status !== "candidate") throw new Error("only an unreviewed candidate can be reviewed");
|
|
356
|
+
if (decision === "accept") {
|
|
357
|
+
if (!confirmedByUser) throw new Error("acceptance requires explicit user confirmation");
|
|
358
|
+
const accepted = acceptCandidate(state, candidate, timestamp, false, null);
|
|
359
|
+
accepted.review = { decision, reason: reviewReason, confirmedByUser: true, reviewedAt: timestamp, authority: "context-only" };
|
|
360
|
+
return { candidate: accepted, learningPath };
|
|
361
|
+
}
|
|
362
|
+
preserve(state, "learning-candidate", candidate, timestamp);
|
|
363
|
+
const rejected = {
|
|
364
|
+
...candidate,
|
|
365
|
+
status: "rejected",
|
|
366
|
+
updatedAt: timestamp,
|
|
367
|
+
review: { decision, reason: reviewReason, confirmedByUser: false, reviewedAt: timestamp, authority: "context-only" },
|
|
368
|
+
authority: "context-only"
|
|
369
|
+
};
|
|
370
|
+
state.candidates = state.candidates.map((entry) => entry.id === id ? rejected : entry);
|
|
371
|
+
return { candidate: rejected, learningPath };
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function distinctEvidence(candidate) {
|
|
376
|
+
return new Set(candidate.evidence.map((item) => item.sourceSha256 || item.sourceDocument || item.id)).size;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function evaluateLearning({ root = process.cwd(), now = new Date() } = {}) {
|
|
380
|
+
const timestamp = date(now, "now");
|
|
381
|
+
return mutation(root, (state, _catalog, learningPath) => {
|
|
382
|
+
const accepted = [];
|
|
383
|
+
if (state.config.autoPromote) {
|
|
384
|
+
for (const candidate of state.candidates.filter((entry) => entry.status === "candidate")) {
|
|
385
|
+
if (!AUTO_KINDS.has(candidate.kind)) continue;
|
|
386
|
+
if (candidate.confidence < state.config.minConfidence) continue;
|
|
387
|
+
if (distinctEvidence(candidate) < state.config.minEvidence) continue;
|
|
388
|
+
accepted.push(acceptCandidate(state, candidate, timestamp, true, {
|
|
389
|
+
mode: "automatic-low-risk",
|
|
390
|
+
minConfidence: state.config.minConfidence,
|
|
391
|
+
minEvidence: state.config.minEvidence,
|
|
392
|
+
evidenceCount: distinctEvidence(candidate),
|
|
393
|
+
evaluatedAt: timestamp,
|
|
394
|
+
authority: "context-only"
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return { enabled: state.config.autoPromote, accepted, learningPath, authority: "context-only" };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Internal runtime promotion path for locally opted-in continuity signals.
|
|
404
|
+
* This is intentionally not exposed over MCP. The caller must provide the
|
|
405
|
+
* directness, confidence, repetition and local opt-in proof recorded by the
|
|
406
|
+
* continuity state machine.
|
|
407
|
+
*/
|
|
408
|
+
export async function acceptContinuityLearning({
|
|
409
|
+
root = process.cwd(), id, proof, now = new Date()
|
|
410
|
+
}) {
|
|
411
|
+
if (!ID_RE.test(id || "")) throw new Error("id is required");
|
|
412
|
+
if (!proof || proof.mode !== "automatic-continuity-low-risk" || proof.localOptIn !== true) {
|
|
413
|
+
throw new Error("continuity promotion requires a recorded local opt-in proof");
|
|
414
|
+
}
|
|
415
|
+
const timestamp = date(now, "now");
|
|
416
|
+
return mutation(root, (state, _catalog, learningPath) => {
|
|
417
|
+
const candidate = state.candidates.find((entry) => entry.id === id);
|
|
418
|
+
if (!candidate) throw new Error(`unknown learning candidate: ${id}`);
|
|
419
|
+
if (candidate.status === "accepted") return { candidate, learningPath, unchanged: true };
|
|
420
|
+
if (candidate.status !== "candidate") throw new Error("only an active candidate can be promoted");
|
|
421
|
+
if (!CONTINUITY_AUTO_KINDS.has(candidate.kind)) throw new Error("learning kind is not eligible for continuity promotion");
|
|
422
|
+
const minConfidence = number(proof.minConfidence, "proof.minConfidence", 0.9, 1);
|
|
423
|
+
const minEvidence = integer(proof.minEvidence, "proof.minEvidence", 1, 10);
|
|
424
|
+
const minDirectness = number(proof.minDirectness, "proof.minDirectness", 0.9, 1);
|
|
425
|
+
const directness = number(proof.directness, "proof.directness", 0, 1);
|
|
426
|
+
const evidenceCount = distinctEvidence(candidate);
|
|
427
|
+
if (candidate.confidence < minConfidence || directness < minDirectness || evidenceCount < minEvidence) {
|
|
428
|
+
throw new Error("continuity candidate does not meet the recorded promotion thresholds");
|
|
429
|
+
}
|
|
430
|
+
const accepted = acceptCandidate(state, candidate, timestamp, true, {
|
|
431
|
+
mode: "automatic-continuity-low-risk",
|
|
432
|
+
localOptIn: true,
|
|
433
|
+
minConfidence,
|
|
434
|
+
minEvidence,
|
|
435
|
+
minDirectness,
|
|
436
|
+
directness,
|
|
437
|
+
evidenceCount,
|
|
438
|
+
evaluatedAt: timestamp,
|
|
439
|
+
authority: "context-only"
|
|
440
|
+
});
|
|
441
|
+
return { candidate: accepted, learningPath, unchanged: false };
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function rollbackLearning({ root = process.cwd(), id, reason, now = new Date() }) {
|
|
446
|
+
if (!ID_RE.test(id || "")) throw new Error("id is required");
|
|
447
|
+
const rollbackReason = safeText(reason, "reason", 500);
|
|
448
|
+
const timestamp = date(now, "now");
|
|
449
|
+
return mutation(root, (state, _catalog, learningPath) => {
|
|
450
|
+
const candidate = state.candidates.find((entry) => entry.id === id);
|
|
451
|
+
if (!candidate || candidate.status !== "accepted") throw new Error("only an accepted learning can be rolled back");
|
|
452
|
+
preserve(state, "learning-candidate", candidate, timestamp);
|
|
453
|
+
const restored = [];
|
|
454
|
+
for (const previousId of candidate.supersededIds || []) {
|
|
455
|
+
const previous = state.candidates.find((entry) => entry.id === previousId);
|
|
456
|
+
if (previous?.status === "superseded") {
|
|
457
|
+
preserve(state, "learning-candidate", previous, timestamp);
|
|
458
|
+
state.candidates = state.candidates.map((entry) => entry.id === previousId
|
|
459
|
+
? { ...entry, status: "accepted", updatedAt: timestamp, authority: "context-only" }
|
|
460
|
+
: entry);
|
|
461
|
+
restored.push(previousId);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const rolledBack = {
|
|
465
|
+
...candidate,
|
|
466
|
+
status: "rolled-back",
|
|
467
|
+
updatedAt: timestamp,
|
|
468
|
+
rollback: { reason: rollbackReason, rolledBackAt: timestamp, authority: "context-only" },
|
|
469
|
+
authority: "context-only"
|
|
470
|
+
};
|
|
471
|
+
state.candidates = state.candidates.map((entry) => entry.id === id ? rolledBack : entry);
|
|
472
|
+
return { candidate: rolledBack, restored, learningPath };
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function groupEntities(graph, groupId, includePrivate) {
|
|
477
|
+
const result = new Set();
|
|
478
|
+
if (!groupId) return result;
|
|
479
|
+
result.add(groupId);
|
|
480
|
+
for (const edge of graph.entityEdges) {
|
|
481
|
+
if (edge.relation !== "member-of" || (!includePrivate && edge.privacy === "private")) continue;
|
|
482
|
+
if (edge.to === groupId) result.add(edge.from);
|
|
483
|
+
if (edge.from === groupId) result.add(edge.to);
|
|
484
|
+
}
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function visible(candidate, entities, audience, includePrivate, groupId) {
|
|
489
|
+
if (candidate.privacy === "private" && !includePrivate) return false;
|
|
490
|
+
if (candidate.privacy === "group" && (!groupId || candidate.groupId !== groupId)) return false;
|
|
491
|
+
if (candidate.privacy === "group" && candidate.subjectId && !audience.has(candidate.subjectId)) return false;
|
|
492
|
+
const subject = candidate.subjectId ? entities.get(candidate.subjectId) : null;
|
|
493
|
+
if (subject?.privacy === "private" && !includePrivate) return false;
|
|
494
|
+
if (subject?.privacy === "group" && !audience.has(subject.id)) return false;
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function learningContext({
|
|
499
|
+
root = process.cwd(), includePrivate = false, groupId = null, kinds = null,
|
|
500
|
+
subjectIds = null, maxItems = null, catalog: providedCatalog = null
|
|
501
|
+
} = {}) {
|
|
502
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
503
|
+
const { learning } = await loadLearning(catalog.root, catalog);
|
|
504
|
+
if (!validConfig(learning.config)) throw new Error("learning configuration is invalid; run the audit before using learned context");
|
|
505
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
506
|
+
const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
|
|
507
|
+
if (groupId !== null) {
|
|
508
|
+
const group = entities.get(groupId);
|
|
509
|
+
if (!group || group.kind !== "group") throw new Error(`unknown group entity: ${groupId}`);
|
|
510
|
+
}
|
|
511
|
+
const audience = groupEntities(graph, groupId, includePrivate);
|
|
512
|
+
const kindFilter = kinds === null ? null : new Set(kinds);
|
|
513
|
+
if (kindFilter && [...kindFilter].some((kind) => !KINDS.has(kind))) throw new Error("kinds contains an unsupported learning kind");
|
|
514
|
+
const subjectFilter = subjectIds === null ? null : new Set(subjectIds);
|
|
515
|
+
const limit = maxItems === null ? learning.config.maxContextItems : integer(maxItems, "maxItems", 0, 50);
|
|
516
|
+
const items = learning.candidates
|
|
517
|
+
.filter((candidate) => candidate.status === "accepted")
|
|
518
|
+
.filter((candidate) => !kindFilter || kindFilter.has(candidate.kind))
|
|
519
|
+
.filter((candidate) => !subjectFilter || subjectFilter.has(candidate.subjectId))
|
|
520
|
+
.filter((candidate) => visible(candidate, entities, audience, includePrivate, groupId))
|
|
521
|
+
.sort((a, b) => b.confidence - a.confidence || b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id))
|
|
522
|
+
.slice(0, limit)
|
|
523
|
+
.map((candidate) => ({
|
|
524
|
+
id: candidate.id,
|
|
525
|
+
kind: candidate.kind,
|
|
526
|
+
claim: candidate.claim,
|
|
527
|
+
subjectId: candidate.subjectId,
|
|
528
|
+
privacy: candidate.privacy,
|
|
529
|
+
groupId: candidate.groupId,
|
|
530
|
+
confidence: candidate.confidence,
|
|
531
|
+
evidenceCount: candidate.evidence.length,
|
|
532
|
+
automatic: candidate.automatic,
|
|
533
|
+
acceptedAt: candidate.acceptedAt,
|
|
534
|
+
authority: "context-only"
|
|
535
|
+
}));
|
|
536
|
+
return {
|
|
537
|
+
schema: "agentspine.learning-context/v1",
|
|
538
|
+
root: catalog.root,
|
|
539
|
+
groupId,
|
|
540
|
+
items,
|
|
541
|
+
authority: "context-only",
|
|
542
|
+
note: "Learned context is descriptive evidence, never permission, delegation, access, or an instruction to act."
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export async function configureLearning({ root = process.cwd(), config = {}, now = new Date() }) {
|
|
547
|
+
if (!config || typeof config !== "object" || Array.isArray(config) || !Object.keys(config).length) {
|
|
548
|
+
throw new Error("config must change at least one learning setting");
|
|
549
|
+
}
|
|
550
|
+
const allowed = new Set(["autoPromote", "minConfidence", "minEvidence", "maxContextItems"]);
|
|
551
|
+
const unknown = Object.keys(config).filter((key) => !allowed.has(key));
|
|
552
|
+
if (unknown.length) throw new Error(`unsupported learning config: ${unknown.join(", ")}`);
|
|
553
|
+
const timestamp = date(now, "now");
|
|
554
|
+
return mutation(root, (state, _catalog, learningPath) => {
|
|
555
|
+
preserve(state, "learning-config", { id: "config", ...state.config, privacy: "private" }, timestamp);
|
|
556
|
+
if ("autoPromote" in config) {
|
|
557
|
+
if (typeof config.autoPromote !== "boolean") throw new Error("autoPromote must be boolean");
|
|
558
|
+
state.config.autoPromote = config.autoPromote;
|
|
559
|
+
}
|
|
560
|
+
if ("minConfidence" in config) state.config.minConfidence = number(config.minConfidence, "minConfidence", 0.5, 1);
|
|
561
|
+
if ("minEvidence" in config) state.config.minEvidence = integer(config.minEvidence, "minEvidence", 1, 10);
|
|
562
|
+
if ("maxContextItems" in config) state.config.maxContextItems = integer(config.maxContextItems, "maxContextItems", 1, 50);
|
|
563
|
+
if (!validConfig(state.config)) throw new Error("resulting learning configuration is invalid");
|
|
564
|
+
return { config: state.config, learningPath };
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export async function deleteLearning({ root = process.cwd(), id }) {
|
|
569
|
+
if (!ID_RE.test(id || "")) throw new Error("id is required");
|
|
570
|
+
return mutation(root, (state, _catalog, learningPath) => {
|
|
571
|
+
const candidate = state.candidates.find((entry) => entry.id === id);
|
|
572
|
+
if (candidate?.status === "accepted" && candidate.supersededIds?.length) {
|
|
573
|
+
throw new Error("roll back an accepted superseding learning before permanent deletion");
|
|
574
|
+
}
|
|
575
|
+
const existed = Boolean(candidate);
|
|
576
|
+
state.candidates = state.candidates.filter((entry) => entry.id !== id);
|
|
577
|
+
state.history = state.history.filter((entry) => entry.recordId !== id && entry.value?.id !== id);
|
|
578
|
+
return { deleted: existed, id, learningPath };
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export async function purgeLearningBySubject({ root = process.cwd(), subjectId }) {
|
|
583
|
+
if (!ID_RE.test(subjectId || "")) throw new Error("subjectId is required");
|
|
584
|
+
return mutation(root, (state, _catalog, learningPath) => {
|
|
585
|
+
const ids = new Set(state.candidates.filter((entry) => entry.subjectId === subjectId).map((entry) => entry.id));
|
|
586
|
+
state.candidates = state.candidates.filter((entry) => entry.subjectId !== subjectId);
|
|
587
|
+
state.history = state.history.filter((entry) => entry.subjectId !== subjectId && !ids.has(entry.recordId) && !ids.has(entry.value?.id));
|
|
588
|
+
return { deleted: ids.size, subjectId, learningPath };
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function learningFindings(learning, graph) {
|
|
593
|
+
const findings = [];
|
|
594
|
+
if (!validConfig(learning.config)) findings.push("invalid-config");
|
|
595
|
+
const groups = new Set(graph.entities.filter((entity) => entity.kind === "group").map((entity) => entity.id));
|
|
596
|
+
for (const candidate of learning.candidates) {
|
|
597
|
+
if (!KINDS.has(candidate.kind) || !STATUSES.has(candidate.status) || !PRIVACY.has(candidate.privacy)) findings.push(`invalid-candidate:${candidate.id}`);
|
|
598
|
+
const nested = [...(candidate.evidence || []), candidate.review, candidate.rollback, candidate.promotion].filter(Boolean);
|
|
599
|
+
if (candidate.authority !== "context-only" || nested.some((item) => item.authority !== "context-only")) findings.push(`authority:${candidate.id}`);
|
|
600
|
+
if (SECRET_RE.test(candidate.claim || "") || AUTHORITY_ASSERTION_RE.test(candidate.claim || "")) findings.push(`unsafe-claim:${candidate.id}`);
|
|
601
|
+
if (candidate.evidence?.some((item) => SECRET_RE.test(item.summary || ""))) findings.push(`unsafe-evidence:${candidate.id}`);
|
|
602
|
+
const evidenceValid = candidate.evidence.length > 0 && candidate.evidence.every((item) => (
|
|
603
|
+
ID_RE.test(item.id || "") && EVIDENCE_TYPES.has(item.type)
|
|
604
|
+
&& typeof item.summary === "string" && item.summary.length > 0
|
|
605
|
+
&& Number.isFinite(item.confidence) && item.confidence >= 0 && item.confidence <= 1
|
|
606
|
+
&& Number.isFinite(new Date(item.observedAt).getTime())
|
|
607
|
+
&& (item.type !== "document" || (item.sourceDocument && /^[a-f0-9]{64}$/.test(item.sourceSha256 || "")))
|
|
608
|
+
));
|
|
609
|
+
if (!evidenceValid || !Number.isFinite(candidate.confidence)
|
|
610
|
+
|| Math.abs(candidate.confidence - evidenceConfidence(candidate.evidence)) > 1e-12) {
|
|
611
|
+
findings.push(`invalid-evidence:${candidate.id}`);
|
|
612
|
+
}
|
|
613
|
+
if (candidate.privacy === "group" && (!groups.has(candidate.groupId) || !isGroupMember(graph, candidate.groupId, candidate.subjectId))) findings.push(`invalid-group:${candidate.id}`);
|
|
614
|
+
if (candidate.status === "accepted") {
|
|
615
|
+
const manualProof = candidate.automatic === false
|
|
616
|
+
&& candidate.review?.decision === "accept" && candidate.review?.confirmedByUser === true;
|
|
617
|
+
const automaticProof = candidate.automatic === true
|
|
618
|
+
&& ((AUTO_KINDS.has(candidate.kind)
|
|
619
|
+
&& candidate.promotion?.mode === "automatic-low-risk"
|
|
620
|
+
&& candidate.confidence >= candidate.promotion?.minConfidence
|
|
621
|
+
&& distinctEvidence(candidate) >= candidate.promotion?.minEvidence
|
|
622
|
+
&& candidate.promotion?.evidenceCount >= candidate.promotion?.minEvidence)
|
|
623
|
+
|| (CONTINUITY_AUTO_KINDS.has(candidate.kind)
|
|
624
|
+
&& candidate.promotion?.mode === "automatic-continuity-low-risk"
|
|
625
|
+
&& candidate.promotion?.localOptIn === true
|
|
626
|
+
&& candidate.confidence >= candidate.promotion?.minConfidence
|
|
627
|
+
&& candidate.promotion?.directness >= candidate.promotion?.minDirectness
|
|
628
|
+
&& distinctEvidence(candidate) >= candidate.promotion?.minEvidence
|
|
629
|
+
&& candidate.promotion?.evidenceCount >= candidate.promotion?.minEvidence));
|
|
630
|
+
if (!candidate.acceptedAt || (!manualProof && !automaticProof)) findings.push(`invalid-acceptance:${candidate.id}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
for (const entry of learning.history) {
|
|
634
|
+
const value = entry.value || {};
|
|
635
|
+
const nested = [...(value.evidence || []), value.review, value.rollback, value.promotion].filter(Boolean);
|
|
636
|
+
if (entry.authority !== "context-only" || value.authority !== "context-only" || nested.some((item) => item.authority !== "context-only")) {
|
|
637
|
+
findings.push(`history-authority:${entry.recordId || "unknown"}`);
|
|
638
|
+
}
|
|
639
|
+
if (SECRET_RE.test(value.claim || "") || AUTHORITY_ASSERTION_RE.test(value.claim || "")
|
|
640
|
+
|| value.evidence?.some((item) => SECRET_RE.test(item.summary || ""))) {
|
|
641
|
+
findings.push(`unsafe-history:${entry.recordId || "unknown"}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return findings;
|
|
645
|
+
}
|