blun-king-cli 9.1.520 → 9.1.525
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 +32 -0
- package/LIESMICH.txt +4 -2
- package/README.md +4 -2
- package/agent-spine-plugin/.claude-plugin/marketplace.json +1 -1
- package/agent-spine-plugin/.claude-plugin/plugin.json +1 -1
- package/agent-spine-plugin/.codex-plugin/plugin.json +1 -2
- package/agent-spine-plugin/CHANGELOG.md +81 -14
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/README.md +6 -4
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/blun.plugin.json +2 -2
- package/agent-spine-plugin/docs/acceptance.md +2 -2
- package/agent-spine-plugin/docs/architecture.md +1 -1
- package/agent-spine-plugin/docs/gateway-runtime.md +2 -2
- package/agent-spine-plugin/docs/host-integration.md +2 -2
- package/agent-spine-plugin/docs/learning.md +37 -2
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/docs/quality-gates.md +1 -1
- package/agent-spine-plugin/docs/relationships.md +1 -1
- package/agent-spine-plugin/docs/session-briefing.md +1 -1
- package/agent-spine-plugin/docs/source-roots.md +1 -1
- package/agent-spine-plugin/hooks/codex.json +1 -1
- package/agent-spine-plugin/hooks/version.json +1 -1
- package/agent-spine-plugin/package.json +1 -1
- package/agent-spine-plugin/scripts/check-hosts.js +2 -2
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +3 -3
- package/agent-spine-plugin/src/cli.js +69 -5
- package/agent-spine-plugin/src/hook.js +41 -23
- package/agent-spine-plugin/src/index.js +2 -1
- package/agent-spine-plugin/src/lib/acceptance.js +2 -3
- package/agent-spine-plugin/src/lib/attention.js +4 -4
- package/agent-spine-plugin/src/lib/audit.js +12 -3
- package/agent-spine-plugin/src/lib/authentication.js +4 -2
- package/agent-spine-plugin/src/lib/briefing.js +20 -8
- package/agent-spine-plugin/src/lib/catalog.js +28 -3
- package/agent-spine-plugin/src/lib/channel-runtime.js +2 -2
- package/agent-spine-plugin/src/lib/continuity.js +18 -13
- package/agent-spine-plugin/src/lib/documents.js +23 -5
- package/agent-spine-plugin/src/lib/feed-transport.js +4 -2
- package/agent-spine-plugin/src/lib/graph.js +47 -12
- package/agent-spine-plugin/src/lib/learning.js +422 -48
- package/agent-spine-plugin/src/lib/paths.js +50 -1
- package/agent-spine-plugin/src/lib/persona-runtime.js +20 -14
- package/agent-spine-plugin/src/lib/preflight.js +50 -26
- package/agent-spine-plugin/src/lib/source-roots.js +38 -7
- package/agent-spine-plugin/src/mcp.js +50 -4
- package/agent-spine-plugin/src/version.js +1 -1
- package/bin/telegram-approval-relay.cjs +2 -0
- package/blun.mjs +111 -8
- package/package.json +8 -2
- package/scripts/check-approval-observability-regression.js +111 -0
- package/scripts/check-bundled-agent-spine-regression.js +48 -0
- package/scripts/check-session-picker-resume-metrics-regression.js +97 -0
- package/scripts/check-session-start-hook-context-regression.js +54 -4
- package/scripts/check-shell-terminal-isolation-regression.js +81 -0
- package/scripts/check-slash-escape-regression.js +89 -0
- package/scripts/check-telegram-loop-exactly-once-regression.js +71 -0
- package/telegram-plugin/bin/telegram-approval-relay.cjs +2 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { buildCatalog } from "./catalog.js";
|
|
@@ -6,19 +6,36 @@ import { isFileLockContention } from "./filesystem-retry.js";
|
|
|
6
6
|
import { loadGraph } from "./graph.js";
|
|
7
7
|
import { projectStateDir } from "./paths.js";
|
|
8
8
|
|
|
9
|
-
const KINDS = new Set(["preference", "no-go", "goal", "correction", "personal-fact", "project-fact", "reference"]);
|
|
9
|
+
const KINDS = new Set(["preference", "no-go", "goal", "correction", "personal-fact", "project-fact", "reference", "behavior"]);
|
|
10
10
|
const EVIDENCE_TYPES = new Set(["user-statement", "document", "interaction", "test"]);
|
|
11
11
|
const PRIVACY = new Set(["private", "shared", "group"]);
|
|
12
12
|
const STATUSES = new Set(["candidate", "accepted", "rejected", "superseded", "rolled-back"]);
|
|
13
13
|
const AUTO_KINDS = new Set(["project-fact", "reference"]);
|
|
14
|
+
const OUTCOME_AUTO_KINDS = new Set(["behavior"]);
|
|
14
15
|
const CONTINUITY_AUTO_KINDS = new Set(["preference", "no-go", "correction", "project-fact", "reference"]);
|
|
16
|
+
const OUTCOME_PHASES = new Set(["before", "after"]);
|
|
17
|
+
const MEASUREMENT_KINDS = new Set(["objective", "user-feedback", "model-suggestion"]);
|
|
18
|
+
const METRIC_DIRECTIONS = new Set(["higher", "lower"]);
|
|
19
|
+
const SCOPE_FIELDS = ["personaId", "userId", "tenantId", "projectId", "groupId", "taskId"];
|
|
15
20
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
16
21
|
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
17
22
|
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
23
|
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;
|
|
24
|
+
const PROTECTED_LESSON_RE = /\b(?:security|safety|identity|authentication|authorization|permissions?|credentials?|secrets?|policy|production|deployment|payments?|billing|tool access|file access|network access|database access|sicherheit|identität|authentifizierung|berechtigungen?|zugang|richtlinie|produktion|zahlungen?)\b/i;
|
|
19
25
|
|
|
20
26
|
function defaults() {
|
|
21
|
-
return {
|
|
27
|
+
return {
|
|
28
|
+
autoPromote: false,
|
|
29
|
+
minConfidence: 0.85,
|
|
30
|
+
minEvidence: 2,
|
|
31
|
+
maxContextItems: 12,
|
|
32
|
+
minOutcomeReceipts: 2,
|
|
33
|
+
minImprovement: 0.05,
|
|
34
|
+
regressionTolerance: 0,
|
|
35
|
+
outcomeMaxAgeDays: 30,
|
|
36
|
+
canaryReceipts: 2,
|
|
37
|
+
canaryTtlDays: 14
|
|
38
|
+
};
|
|
22
39
|
}
|
|
23
40
|
|
|
24
41
|
function emptyLearning(root) {
|
|
@@ -27,6 +44,7 @@ function emptyLearning(root) {
|
|
|
27
44
|
root,
|
|
28
45
|
config: defaults(),
|
|
29
46
|
candidates: [],
|
|
47
|
+
outcomes: [],
|
|
30
48
|
history: []
|
|
31
49
|
};
|
|
32
50
|
}
|
|
@@ -36,17 +54,89 @@ function normalizeState(value, root) {
|
|
|
36
54
|
|| value.schema !== "agentspine.learning/v1" || value.root !== root
|
|
37
55
|
|| !value.config || typeof value.config !== "object" || Array.isArray(value.config)
|
|
38
56
|
|| !Array.isArray(value.candidates) || !value.candidates.every((item) => item && typeof item === "object" && Array.isArray(item.evidence))
|
|
57
|
+
|| (value.outcomes !== undefined && (!Array.isArray(value.outcomes) || !value.outcomes.every((item) => item && typeof item === "object")))
|
|
39
58
|
|| !Array.isArray(value.history) || !value.history.every((item) => item && typeof item === "object")) {
|
|
40
59
|
throw new Error("learning state structure is invalid; run the audit before learning");
|
|
41
60
|
}
|
|
42
|
-
|
|
61
|
+
const normalized = {
|
|
62
|
+
...value,
|
|
63
|
+
config: { ...defaults(), ...value.config },
|
|
64
|
+
candidates: value.candidates.map((candidate) => ({
|
|
65
|
+
...candidate,
|
|
66
|
+
scope: normalizeStoredScope(candidate.scope, candidate.subjectId, candidate.groupId),
|
|
67
|
+
requiresLocalReview: candidate.requiresLocalReview ?? PROTECTED_LESSON_RE.test(candidate.claim || "")
|
|
68
|
+
})),
|
|
69
|
+
outcomes: value.outcomes || []
|
|
70
|
+
};
|
|
71
|
+
if (normalized.outcomes.some((receipt) => !storedOutcomeStructure(receipt))) {
|
|
72
|
+
throw new Error("learning outcome state is invalid; run the audit before learning");
|
|
73
|
+
}
|
|
74
|
+
if (normalized.outcomes.some((receipt) => {
|
|
75
|
+
const candidate = normalized.candidates.find((item) => item.id === receipt.learningId);
|
|
76
|
+
return !candidate || !scopeContains(candidate.scope, receipt.scope);
|
|
77
|
+
})) throw new Error("learning outcome scope is invalid; run the audit before learning");
|
|
78
|
+
return normalized;
|
|
43
79
|
}
|
|
44
80
|
|
|
45
81
|
function validConfig(config) {
|
|
46
82
|
return typeof config?.autoPromote === "boolean"
|
|
47
83
|
&& Number.isFinite(config.minConfidence) && config.minConfidence >= 0.5 && config.minConfidence <= 1
|
|
48
84
|
&& Number.isInteger(config.minEvidence) && config.minEvidence >= 1 && config.minEvidence <= 10
|
|
49
|
-
&& Number.isInteger(config.maxContextItems) && config.maxContextItems >= 1 && config.maxContextItems <= 50
|
|
85
|
+
&& Number.isInteger(config.maxContextItems) && config.maxContextItems >= 1 && config.maxContextItems <= 50
|
|
86
|
+
&& Number.isInteger(config.minOutcomeReceipts) && config.minOutcomeReceipts >= 2 && config.minOutcomeReceipts <= 10
|
|
87
|
+
&& Number.isFinite(config.minImprovement) && config.minImprovement >= 0 && config.minImprovement <= 1
|
|
88
|
+
&& Number.isFinite(config.regressionTolerance) && config.regressionTolerance >= 0 && config.regressionTolerance <= 1
|
|
89
|
+
&& Number.isInteger(config.outcomeMaxAgeDays) && config.outcomeMaxAgeDays >= 1 && config.outcomeMaxAgeDays <= 365
|
|
90
|
+
&& Number.isInteger(config.canaryReceipts) && config.canaryReceipts >= 1 && config.canaryReceipts <= 10
|
|
91
|
+
&& Number.isInteger(config.canaryTtlDays) && config.canaryTtlDays >= 1 && config.canaryTtlDays <= 90;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeStoredScope(scope, subjectId = null, groupId = null) {
|
|
95
|
+
const source = scope && typeof scope === "object" && !Array.isArray(scope) ? scope : {};
|
|
96
|
+
const normalized = {};
|
|
97
|
+
for (const field of SCOPE_FIELDS) normalized[field] = source[field] ?? null;
|
|
98
|
+
if (normalized.groupId === null && groupId) normalized.groupId = groupId;
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeScope(scope, subjectId = null, groupId = null) {
|
|
103
|
+
const normalized = normalizeStoredScope(scope, subjectId, groupId);
|
|
104
|
+
for (const [field, value] of Object.entries(normalized)) {
|
|
105
|
+
if (value !== null && !ID_RE.test(value)) throw new Error(`scope.${field} must be a stable, whitespace-free identifier`);
|
|
106
|
+
}
|
|
107
|
+
return normalized;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scopeKey(scope) {
|
|
111
|
+
return JSON.stringify(SCOPE_FIELDS.map((field) => scope?.[field] ?? null));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function scopeContains(candidateScope, runtimeScope) {
|
|
115
|
+
return SCOPE_FIELDS.every((field) => candidateScope?.[field] === null || candidateScope?.[field] === runtimeScope?.[field]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function exactScope(left, right) {
|
|
119
|
+
return scopeKey(left) === scopeKey(right);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function digest(value) {
|
|
123
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function storedOutcomeStructure(receipt) {
|
|
127
|
+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return false;
|
|
128
|
+
const payload = outcomePayload(receipt);
|
|
129
|
+
return receipt.schema === "agentspine.learning-outcome/v1" && ID_RE.test(receipt.id || "")
|
|
130
|
+
&& ID_RE.test(receipt.learningId || "") && OUTCOME_PHASES.has(receipt.phase)
|
|
131
|
+
&& SCOPE_FIELDS.every((field) => receipt.scope?.[field] === null || ID_RE.test(receipt.scope?.[field] || ""))
|
|
132
|
+
&& typeof receipt.metric?.name === "string" && receipt.metric.name.length > 0
|
|
133
|
+
&& METRIC_DIRECTIONS.has(receipt.metric?.direction)
|
|
134
|
+
&& Number.isFinite(receipt.metric?.value) && receipt.metric.value >= 0 && receipt.metric.value <= 1
|
|
135
|
+
&& Number.isInteger(receipt.metric?.blockingDefects) && receipt.metric.blockingDefects >= 0
|
|
136
|
+
&& MEASUREMENT_KINDS.has(receipt.measurement?.kind) && ID_RE.test(receipt.measurement?.evaluatorId || "")
|
|
137
|
+
&& (receipt.measurement?.sourceDigest === null || /^[a-f0-9]{64}$/.test(receipt.measurement?.sourceDigest || ""))
|
|
138
|
+
&& receipt.authority === "context-only" && receipt.measurement?.authority === "context-only"
|
|
139
|
+
&& Number.isFinite(new Date(receipt.measuredAt).getTime()) && receipt.digest === digest(payload);
|
|
50
140
|
}
|
|
51
141
|
|
|
52
142
|
function date(value, field = "date") {
|
|
@@ -197,8 +287,8 @@ async function withLock(path, root, task) {
|
|
|
197
287
|
}
|
|
198
288
|
}
|
|
199
289
|
|
|
200
|
-
async function mutation(root, operation) {
|
|
201
|
-
const catalog = await buildCatalog(root);
|
|
290
|
+
async function mutation(root, operation, providedCatalog = null) {
|
|
291
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
202
292
|
const { learningPath } = await loadLearning(catalog.root, catalog);
|
|
203
293
|
return withLock(learningPath, catalog.root, async (state) => operation(state, catalog, learningPath));
|
|
204
294
|
}
|
|
@@ -249,12 +339,15 @@ function evidenceConfidence(evidence) {
|
|
|
249
339
|
|
|
250
340
|
export async function proposeLearning({
|
|
251
341
|
root = process.cwd(), id = `learning:${randomUUID()}`, kind, claim, subjectId = null,
|
|
252
|
-
privacy = "private", groupId = null, evidence, supersedesId = null, now = new Date()
|
|
342
|
+
privacy = "private", groupId = null, scope = null, evidence, supersedesId = null, now = new Date(),
|
|
343
|
+
catalog: providedCatalog = null
|
|
253
344
|
}) {
|
|
254
345
|
if (!ID_RE.test(id)) throw new Error("id must be a stable, whitespace-free identifier");
|
|
255
346
|
if (!KINDS.has(kind)) throw new Error(`unsupported learning kind: ${kind}`);
|
|
256
347
|
claim = safeText(claim, "claim", 1000);
|
|
257
348
|
assertSafeClaim(claim);
|
|
349
|
+
const normalizedScope = normalizeScope(scope, subjectId, groupId);
|
|
350
|
+
if (normalizedScope.groupId !== (groupId ?? null)) throw new Error("scope.groupId must match the privacy groupId");
|
|
258
351
|
const timestamp = date(now, "now");
|
|
259
352
|
return mutation(root, async (state, catalog, learningPath) => {
|
|
260
353
|
if (state.candidates.some((candidate) => candidate.id === id)) {
|
|
@@ -262,14 +355,28 @@ export async function proposeLearning({
|
|
|
262
355
|
}
|
|
263
356
|
const { graph } = await loadGraph(catalog.root, catalog);
|
|
264
357
|
validateScope(privacy, groupId, graph, subjectId);
|
|
358
|
+
const duplicate = state.candidates.find((candidate) => candidate.kind === kind
|
|
359
|
+
&& candidate.claim === claim && exactScope(candidate.scope, normalizedScope)
|
|
360
|
+
&& candidate.privacy === privacy && candidate.status !== "rejected" && candidate.status !== "rolled-back");
|
|
361
|
+
if (duplicate) return { candidate: duplicate, learningPath, unchanged: true };
|
|
265
362
|
const normalizedEvidence = normalizeEvidence(evidence, catalog, timestamp);
|
|
266
363
|
const superseded = supersedesId ? state.candidates.find((candidate) => candidate.id === supersedesId) : null;
|
|
267
364
|
if (supersedesId && (!superseded || superseded.status !== "accepted")) {
|
|
268
365
|
throw new Error(`supersedesId must reference an accepted learning: ${supersedesId}`);
|
|
269
366
|
}
|
|
270
|
-
if (superseded && (superseded.kind !== kind || superseded.subjectId !== subjectId || superseded.privacy !== privacy
|
|
367
|
+
if (superseded && (superseded.kind !== kind || superseded.subjectId !== subjectId || superseded.privacy !== privacy
|
|
368
|
+
|| superseded.groupId !== groupId || !exactScope(superseded.scope, normalizedScope))) {
|
|
271
369
|
throw new Error("a superseding candidate must keep kind, subject, and privacy scope");
|
|
272
370
|
}
|
|
371
|
+
const conflictsWith = state.candidates.filter((candidate) => candidate.kind === kind
|
|
372
|
+
&& candidate.claim !== claim && exactScope(candidate.scope, normalizedScope)
|
|
373
|
+
&& ["candidate", "accepted"].includes(candidate.status) && candidate.id !== supersedesId)
|
|
374
|
+
.map((candidate) => candidate.id).sort();
|
|
375
|
+
if (conflictsWith.length) {
|
|
376
|
+
state.candidates = state.candidates.map((candidate) => conflictsWith.includes(candidate.id)
|
|
377
|
+
? { ...candidate, conflictsWith: [...new Set([...(candidate.conflictsWith || []), id])].sort(), updatedAt: timestamp }
|
|
378
|
+
: candidate);
|
|
379
|
+
}
|
|
273
380
|
const candidate = {
|
|
274
381
|
id,
|
|
275
382
|
kind,
|
|
@@ -277,11 +384,14 @@ export async function proposeLearning({
|
|
|
277
384
|
subjectId,
|
|
278
385
|
privacy,
|
|
279
386
|
groupId,
|
|
387
|
+
scope: normalizedScope,
|
|
280
388
|
status: "candidate",
|
|
281
389
|
evidence: [normalizedEvidence],
|
|
282
390
|
confidence: normalizedEvidence.confidence,
|
|
283
391
|
supersedesId,
|
|
284
392
|
supersededIds: [],
|
|
393
|
+
conflictsWith,
|
|
394
|
+
requiresLocalReview: PROTECTED_LESSON_RE.test(claim),
|
|
285
395
|
automatic: false,
|
|
286
396
|
createdAt: timestamp,
|
|
287
397
|
updatedAt: timestamp,
|
|
@@ -291,10 +401,12 @@ export async function proposeLearning({
|
|
|
291
401
|
state.candidates.push(candidate);
|
|
292
402
|
state.candidates.sort((a, b) => a.id.localeCompare(b.id));
|
|
293
403
|
return { candidate, learningPath };
|
|
294
|
-
});
|
|
404
|
+
}, providedCatalog);
|
|
295
405
|
}
|
|
296
406
|
|
|
297
|
-
export async function addLearningEvidence({
|
|
407
|
+
export async function addLearningEvidence({
|
|
408
|
+
root = process.cwd(), id, evidence, now = new Date(), catalog: providedCatalog = null
|
|
409
|
+
}) {
|
|
298
410
|
if (!ID_RE.test(id || "")) throw new Error("id is required");
|
|
299
411
|
const timestamp = date(now, "now");
|
|
300
412
|
return mutation(root, (state, catalog, learningPath) => {
|
|
@@ -313,7 +425,7 @@ export async function addLearningEvidence({ root = process.cwd(), id, evidence,
|
|
|
313
425
|
};
|
|
314
426
|
state.candidates = state.candidates.map((entry) => entry.id === id ? candidate : entry);
|
|
315
427
|
return { candidate, learningPath };
|
|
316
|
-
});
|
|
428
|
+
}, providedCatalog);
|
|
317
429
|
}
|
|
318
430
|
|
|
319
431
|
function acceptCandidate(state, candidate, timestamp, automatic, promotion = null) {
|
|
@@ -376,26 +488,222 @@ function distinctEvidence(candidate) {
|
|
|
376
488
|
return new Set(candidate.evidence.map((item) => item.sourceSha256 || item.sourceDocument || item.id)).size;
|
|
377
489
|
}
|
|
378
490
|
|
|
491
|
+
function outcomePayload({ id, learningId, phase, scope, metric, measurement, measuredAt }) {
|
|
492
|
+
return {
|
|
493
|
+
schema: "agentspine.learning-outcome/v1",
|
|
494
|
+
id,
|
|
495
|
+
learningId,
|
|
496
|
+
phase,
|
|
497
|
+
scope,
|
|
498
|
+
metric,
|
|
499
|
+
measurement,
|
|
500
|
+
measuredAt,
|
|
501
|
+
authority: "context-only"
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function normalizeOutcome(input, candidate, timestamp) {
|
|
506
|
+
const id = input.id || `outcome:${randomUUID()}`;
|
|
507
|
+
if (!ID_RE.test(id)) throw new Error("outcome.id must be a stable, whitespace-free identifier");
|
|
508
|
+
const phase = input.phase;
|
|
509
|
+
if (!OUTCOME_PHASES.has(phase)) throw new Error("outcome.phase must be before or after");
|
|
510
|
+
const scope = normalizeScope(input.scope);
|
|
511
|
+
if (!scopeContains(candidate.scope, scope)) throw new Error("outcome scope does not match the learning candidate");
|
|
512
|
+
const name = safeText(input.metric?.name, "outcome.metric.name", 120);
|
|
513
|
+
const direction = input.metric?.direction;
|
|
514
|
+
if (!METRIC_DIRECTIONS.has(direction)) throw new Error("outcome.metric.direction must be higher or lower");
|
|
515
|
+
const metric = {
|
|
516
|
+
name,
|
|
517
|
+
direction,
|
|
518
|
+
value: number(input.metric?.value, "outcome.metric.value", 0, 1),
|
|
519
|
+
blockingDefects: integer(input.metric?.blockingDefects ?? 0, "outcome.metric.blockingDefects", 0, 1000)
|
|
520
|
+
};
|
|
521
|
+
const kind = input.measurement?.kind;
|
|
522
|
+
if (!MEASUREMENT_KINDS.has(kind)) throw new Error("outcome.measurement.kind is unsupported");
|
|
523
|
+
const evaluatorId = input.measurement?.evaluatorId;
|
|
524
|
+
if (!ID_RE.test(evaluatorId || "")) throw new Error("outcome.measurement.evaluatorId is required");
|
|
525
|
+
const sourceDigest = input.measurement?.sourceDigest ?? null;
|
|
526
|
+
if (sourceDigest !== null && !/^[a-f0-9]{64}$/.test(sourceDigest)) {
|
|
527
|
+
throw new Error("outcome.measurement.sourceDigest must be a SHA-256 digest");
|
|
528
|
+
}
|
|
529
|
+
const measurement = { kind, evaluatorId, sourceDigest, authority: "context-only" };
|
|
530
|
+
const measuredAt = date(input.measuredAt || timestamp, "outcome.measuredAt");
|
|
531
|
+
const payload = outcomePayload({ id, learningId: candidate.id, phase, scope, metric, measurement, measuredAt });
|
|
532
|
+
return { ...payload, digest: digest(payload) };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function outcomeFresh(receipt, config, now) {
|
|
536
|
+
return new Date(receipt.measuredAt).getTime() >= new Date(now).getTime() - config.outcomeMaxAgeDays * 86400000;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function promotableReceipts(state, candidate, timestamp) {
|
|
540
|
+
const all = state.outcomes.filter((item) => item.learningId === candidate.id && item.phase === "before"
|
|
541
|
+
&& outcomeFresh(item, state.config, timestamp));
|
|
542
|
+
const eligible = all.filter((item) => item.measurement.kind !== "model-suggestion");
|
|
543
|
+
if (!eligible.some((item) => item.measurement.kind === "objective")) return [];
|
|
544
|
+
const groups = new Map();
|
|
545
|
+
for (const item of eligible) {
|
|
546
|
+
const key = JSON.stringify([scopeKey(item.scope), item.metric.name, item.metric.direction]);
|
|
547
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
548
|
+
groups.get(key).push(item);
|
|
549
|
+
}
|
|
550
|
+
return [...groups.values()]
|
|
551
|
+
.filter((items) => new Set(items.map((item) => item.measurement.evaluatorId)).size >= state.config.minOutcomeReceipts)
|
|
552
|
+
.sort((a, b) => b.length - a.length || a[0].id.localeCompare(b[0].id))[0] || [];
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function improvement(direction, baseline, value) {
|
|
556
|
+
return direction === "higher" ? value - baseline : baseline - value;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function rollbackCandidate(state, candidate, reason, timestamp, mode = "manual") {
|
|
560
|
+
preserve(state, "learning-candidate", candidate, timestamp);
|
|
561
|
+
const restored = [];
|
|
562
|
+
for (const previousId of candidate.supersededIds || []) {
|
|
563
|
+
const previous = state.candidates.find((entry) => entry.id === previousId);
|
|
564
|
+
if (previous?.status === "superseded") {
|
|
565
|
+
preserve(state, "learning-candidate", previous, timestamp);
|
|
566
|
+
state.candidates = state.candidates.map((entry) => entry.id === previousId
|
|
567
|
+
? { ...entry, status: "accepted", updatedAt: timestamp, authority: "context-only" }
|
|
568
|
+
: entry);
|
|
569
|
+
restored.push(previousId);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const rolledBack = {
|
|
573
|
+
...candidate,
|
|
574
|
+
status: "rolled-back",
|
|
575
|
+
updatedAt: timestamp,
|
|
576
|
+
rollback: { reason, mode, rolledBackAt: timestamp, authority: "context-only" },
|
|
577
|
+
authority: "context-only"
|
|
578
|
+
};
|
|
579
|
+
state.candidates = state.candidates.map((entry) => entry.id === candidate.id ? rolledBack : entry);
|
|
580
|
+
return { candidate: rolledBack, restored };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function reconcileCanary(state, candidate, timestamp) {
|
|
584
|
+
const canary = candidate.promotion?.canary;
|
|
585
|
+
if (candidate.status !== "accepted" || candidate.promotion?.mode !== "outcome-canary" || canary?.status !== "active") {
|
|
586
|
+
return { candidate, decision: "unchanged", restored: [] };
|
|
587
|
+
}
|
|
588
|
+
if (new Date(canary.expiresAt).getTime() < new Date(timestamp).getTime()) {
|
|
589
|
+
const result = rollbackCandidate(state, candidate, "outcome canary expired before validation", timestamp, "automatic-stale");
|
|
590
|
+
return { ...result, decision: "rolled-back" };
|
|
591
|
+
}
|
|
592
|
+
const receipts = state.outcomes.filter((item) => item.learningId === candidate.id && item.phase === "after"
|
|
593
|
+
&& exactScope(item.scope, canary.scope) && item.metric.name === canary.metric.name
|
|
594
|
+
&& item.metric.direction === canary.metric.direction && outcomeFresh(item, state.config, timestamp));
|
|
595
|
+
if (receipts.some((item) => item.metric.blockingDefects > 0)) {
|
|
596
|
+
const result = rollbackCandidate(state, candidate, "outcome canary recorded a blocking defect", timestamp, "automatic-regression");
|
|
597
|
+
return { ...result, decision: "rolled-back" };
|
|
598
|
+
}
|
|
599
|
+
const eligible = receipts.filter((item) => item.measurement.kind !== "model-suggestion");
|
|
600
|
+
const independent = new Set(eligible.map((item) => item.measurement.evaluatorId)).size;
|
|
601
|
+
const deltas = eligible.map((item) => improvement(canary.metric.direction, canary.baseline, item.metric.value));
|
|
602
|
+
if (deltas.some((value) => value < -state.config.regressionTolerance)) {
|
|
603
|
+
const result = rollbackCandidate(state, candidate, "outcome canary regressed against its baseline", timestamp, "automatic-regression");
|
|
604
|
+
return { ...result, decision: "rolled-back" };
|
|
605
|
+
}
|
|
606
|
+
if (independent < state.config.canaryReceipts || !eligible.some((item) => item.measurement.kind === "objective")) {
|
|
607
|
+
return { candidate, decision: "active", restored: [] };
|
|
608
|
+
}
|
|
609
|
+
const average = deltas.reduce((sum, value) => sum + value, 0) / deltas.length;
|
|
610
|
+
if (average < state.config.minImprovement) {
|
|
611
|
+
const result = rollbackCandidate(state, candidate, "outcome canary did not meet the minimum measured improvement", timestamp, "automatic-no-improvement");
|
|
612
|
+
return { ...result, decision: "rolled-back" };
|
|
613
|
+
}
|
|
614
|
+
preserve(state, "learning-candidate", candidate, timestamp);
|
|
615
|
+
const validated = {
|
|
616
|
+
...candidate,
|
|
617
|
+
promotion: {
|
|
618
|
+
...candidate.promotion,
|
|
619
|
+
canary: { ...canary, status: "validated", validatedAt: timestamp, afterReceipts: eligible.map((item) => item.id), improvement: average }
|
|
620
|
+
},
|
|
621
|
+
updatedAt: timestamp,
|
|
622
|
+
authority: "context-only"
|
|
623
|
+
};
|
|
624
|
+
state.candidates = state.candidates.map((entry) => entry.id === candidate.id ? validated : entry);
|
|
625
|
+
return { candidate: validated, decision: "validated", restored: [] };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export async function recordLearningOutcome({ root = process.cwd(), id, learningId, phase, scope, metric, measurement, measuredAt, now = new Date() }) {
|
|
629
|
+
if (!ID_RE.test(learningId || "")) throw new Error("learningId is required");
|
|
630
|
+
const timestamp = date(now, "now");
|
|
631
|
+
return mutation(root, (state, _catalog, learningPath) => {
|
|
632
|
+
const candidate = state.candidates.find((entry) => entry.id === learningId);
|
|
633
|
+
if (!candidate) throw new Error(`unknown learning candidate: ${learningId}`);
|
|
634
|
+
if (phase === "before" && candidate.status !== "candidate") throw new Error("before outcomes require an unreviewed candidate");
|
|
635
|
+
if (phase === "after" && (candidate.status !== "accepted" || candidate.promotion?.mode !== "outcome-canary")) {
|
|
636
|
+
throw new Error("after outcomes require an active outcome canary");
|
|
637
|
+
}
|
|
638
|
+
const receipt = normalizeOutcome({ id, phase, scope, metric, measurement, measuredAt }, candidate, timestamp);
|
|
639
|
+
const existing = state.outcomes.find((item) => item.id === receipt.id);
|
|
640
|
+
if (existing) {
|
|
641
|
+
const retry = measuredAt === undefined
|
|
642
|
+
? normalizeOutcome({ id, phase, scope, metric, measurement, measuredAt: existing.measuredAt }, candidate, timestamp)
|
|
643
|
+
: receipt;
|
|
644
|
+
if (existing.digest === retry.digest) return { receipt: existing, candidate, decision: "unchanged", learningPath, unchanged: true };
|
|
645
|
+
throw new Error("outcome receipt IDs are immutable");
|
|
646
|
+
}
|
|
647
|
+
const duplicate = state.outcomes.find((item) => item.digest === receipt.digest);
|
|
648
|
+
if (duplicate) return { receipt: duplicate, candidate, decision: "unchanged", learningPath, unchanged: true };
|
|
649
|
+
state.outcomes.push(receipt);
|
|
650
|
+
state.outcomes.sort((a, b) => a.id.localeCompare(b.id));
|
|
651
|
+
const reconciled = phase === "after" ? reconcileCanary(state, candidate, timestamp) : { candidate, decision: "recorded", restored: [] };
|
|
652
|
+
return { receipt, ...reconciled, learningPath, unchanged: false };
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
379
656
|
export async function evaluateLearning({ root = process.cwd(), now = new Date() } = {}) {
|
|
380
657
|
const timestamp = date(now, "now");
|
|
381
658
|
return mutation(root, (state, _catalog, learningPath) => {
|
|
382
659
|
const accepted = [];
|
|
660
|
+
const reconciled = [];
|
|
661
|
+
for (const current of state.candidates.filter((entry) => entry.status === "accepted" && entry.promotion?.mode === "outcome-canary")) {
|
|
662
|
+
const result = reconcileCanary(state, current, timestamp);
|
|
663
|
+
if (result.decision !== "unchanged" && result.decision !== "active") reconciled.push({ id: current.id, decision: result.decision });
|
|
664
|
+
}
|
|
383
665
|
if (state.config.autoPromote) {
|
|
384
666
|
for (const candidate of state.candidates.filter((entry) => entry.status === "candidate")) {
|
|
385
|
-
if (!AUTO_KINDS.has(candidate.kind)) continue;
|
|
386
667
|
if (candidate.confidence < state.config.minConfidence) continue;
|
|
387
668
|
if (distinctEvidence(candidate) < state.config.minEvidence) continue;
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
669
|
+
if (candidate.conflictsWith?.some((id) => state.candidates.some((entry) => entry.id === id && ["candidate", "accepted"].includes(entry.status)))) continue;
|
|
670
|
+
if (OUTCOME_AUTO_KINDS.has(candidate.kind)) {
|
|
671
|
+
if (SCOPE_FIELDS.every((field) => candidate.scope?.[field] === null)) continue;
|
|
672
|
+
if (candidate.requiresLocalReview) continue;
|
|
673
|
+
const receipts = promotableReceipts(state, candidate, timestamp);
|
|
674
|
+
if (receipts.length < state.config.minOutcomeReceipts) continue;
|
|
675
|
+
const baseline = receipts.reduce((sum, item) => sum + item.metric.value, 0) / receipts.length;
|
|
676
|
+
accepted.push(acceptCandidate(state, candidate, timestamp, true, {
|
|
677
|
+
mode: "outcome-canary",
|
|
678
|
+
minConfidence: state.config.minConfidence,
|
|
679
|
+
minEvidence: state.config.minEvidence,
|
|
680
|
+
evidenceCount: distinctEvidence(candidate),
|
|
681
|
+
evaluatedAt: timestamp,
|
|
682
|
+
canary: {
|
|
683
|
+
status: "active",
|
|
684
|
+
scope: receipts[0].scope,
|
|
685
|
+
metric: { name: receipts[0].metric.name, direction: receipts[0].metric.direction },
|
|
686
|
+
baseline,
|
|
687
|
+
beforeReceipts: receipts.map((item) => item.id),
|
|
688
|
+
expiresAt: new Date(new Date(timestamp).getTime() + state.config.canaryTtlDays * 86400000).toISOString()
|
|
689
|
+
},
|
|
690
|
+
authority: "context-only"
|
|
691
|
+
}));
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (AUTO_KINDS.has(candidate.kind)) {
|
|
695
|
+
accepted.push(acceptCandidate(state, candidate, timestamp, true, {
|
|
696
|
+
mode: "automatic-low-risk",
|
|
697
|
+
minConfidence: state.config.minConfidence,
|
|
698
|
+
minEvidence: state.config.minEvidence,
|
|
699
|
+
evidenceCount: distinctEvidence(candidate),
|
|
700
|
+
evaluatedAt: timestamp,
|
|
701
|
+
authority: "context-only"
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
396
704
|
}
|
|
397
705
|
}
|
|
398
|
-
return { enabled: state.config.autoPromote, accepted, learningPath, authority: "context-only" };
|
|
706
|
+
return { enabled: state.config.autoPromote, accepted, reconciled, learningPath, authority: "context-only" };
|
|
399
707
|
});
|
|
400
708
|
}
|
|
401
709
|
|
|
@@ -406,7 +714,7 @@ export async function evaluateLearning({ root = process.cwd(), now = new Date()
|
|
|
406
714
|
* continuity state machine.
|
|
407
715
|
*/
|
|
408
716
|
export async function acceptContinuityLearning({
|
|
409
|
-
root = process.cwd(), id, proof, now = new Date()
|
|
717
|
+
root = process.cwd(), id, proof, now = new Date(), catalog: providedCatalog = null
|
|
410
718
|
}) {
|
|
411
719
|
if (!ID_RE.test(id || "")) throw new Error("id is required");
|
|
412
720
|
if (!proof || proof.mode !== "automatic-continuity-low-risk" || proof.localOptIn !== true) {
|
|
@@ -439,7 +747,7 @@ export async function acceptContinuityLearning({
|
|
|
439
747
|
authority: "context-only"
|
|
440
748
|
});
|
|
441
749
|
return { candidate: accepted, learningPath, unchanged: false };
|
|
442
|
-
});
|
|
750
|
+
}, providedCatalog);
|
|
443
751
|
}
|
|
444
752
|
|
|
445
753
|
export async function rollbackLearning({ root = process.cwd(), id, reason, now = new Date() }) {
|
|
@@ -449,27 +757,8 @@ export async function rollbackLearning({ root = process.cwd(), id, reason, now =
|
|
|
449
757
|
return mutation(root, (state, _catalog, learningPath) => {
|
|
450
758
|
const candidate = state.candidates.find((entry) => entry.id === id);
|
|
451
759
|
if (!candidate || candidate.status !== "accepted") throw new Error("only an accepted learning can be rolled back");
|
|
452
|
-
|
|
453
|
-
|
|
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 };
|
|
760
|
+
const result = rollbackCandidate(state, candidate, rollbackReason, timestamp, "manual");
|
|
761
|
+
return { ...result, learningPath };
|
|
473
762
|
});
|
|
474
763
|
}
|
|
475
764
|
|
|
@@ -497,7 +786,7 @@ function visible(candidate, entities, audience, includePrivate, groupId) {
|
|
|
497
786
|
|
|
498
787
|
export async function learningContext({
|
|
499
788
|
root = process.cwd(), includePrivate = false, groupId = null, kinds = null,
|
|
500
|
-
subjectIds = null, maxItems = null, catalog: providedCatalog = null
|
|
789
|
+
subjectIds = null, scope = null, maxItems = null, catalog: providedCatalog = null, now = new Date()
|
|
501
790
|
} = {}) {
|
|
502
791
|
const catalog = providedCatalog || await buildCatalog(root);
|
|
503
792
|
const { learning } = await loadLearning(catalog.root, catalog);
|
|
@@ -509,12 +798,21 @@ export async function learningContext({
|
|
|
509
798
|
if (!group || group.kind !== "group") throw new Error(`unknown group entity: ${groupId}`);
|
|
510
799
|
}
|
|
511
800
|
const audience = groupEntities(graph, groupId, includePrivate);
|
|
801
|
+
const runtimeScope = normalizeScope(scope, null, groupId);
|
|
512
802
|
const kindFilter = kinds === null ? null : new Set(kinds);
|
|
513
803
|
if (kindFilter && [...kindFilter].some((kind) => !KINDS.has(kind))) throw new Error("kinds contains an unsupported learning kind");
|
|
514
804
|
const subjectFilter = subjectIds === null ? null : new Set(subjectIds);
|
|
515
805
|
const limit = maxItems === null ? learning.config.maxContextItems : integer(maxItems, "maxItems", 0, 50);
|
|
806
|
+
const timestamp = date(now, "now");
|
|
807
|
+
const stale = learning.candidates.filter((candidate) => candidate.status === "accepted"
|
|
808
|
+
&& candidate.promotion?.mode === "outcome-canary" && candidate.promotion.canary?.status === "active"
|
|
809
|
+
&& new Date(candidate.promotion.canary.expiresAt).getTime() < new Date(timestamp).getTime()).map((candidate) => candidate.id);
|
|
516
810
|
const items = learning.candidates
|
|
517
811
|
.filter((candidate) => candidate.status === "accepted")
|
|
812
|
+
.filter((candidate) => !stale.includes(candidate.id))
|
|
813
|
+
.filter((candidate) => scope === null || scopeContains(candidate.scope, runtimeScope))
|
|
814
|
+
.filter((candidate) => candidate.promotion?.mode !== "outcome-canary"
|
|
815
|
+
|| exactScope(candidate.promotion.canary.scope, runtimeScope))
|
|
518
816
|
.filter((candidate) => !kindFilter || kindFilter.has(candidate.kind))
|
|
519
817
|
.filter((candidate) => !subjectFilter || subjectFilter.has(candidate.subjectId))
|
|
520
818
|
.filter((candidate) => visible(candidate, entities, audience, includePrivate, groupId))
|
|
@@ -531,23 +829,62 @@ export async function learningContext({
|
|
|
531
829
|
evidenceCount: candidate.evidence.length,
|
|
532
830
|
automatic: candidate.automatic,
|
|
533
831
|
acceptedAt: candidate.acceptedAt,
|
|
832
|
+
outcomeStatus: candidate.promotion?.mode === "outcome-canary" ? candidate.promotion.canary.status : "not-required",
|
|
534
833
|
authority: "context-only"
|
|
535
834
|
}));
|
|
536
835
|
return {
|
|
537
836
|
schema: "agentspine.learning-context/v1",
|
|
538
837
|
root: catalog.root,
|
|
539
838
|
groupId,
|
|
839
|
+
scope: runtimeScope,
|
|
540
840
|
items,
|
|
841
|
+
degraded: stale.length > 0,
|
|
842
|
+
diagnostics: stale.map((id) => `stale-outcome-canary:${id}`),
|
|
541
843
|
authority: "context-only",
|
|
542
844
|
note: "Learned context is descriptive evidence, never permission, delegation, access, or an instruction to act."
|
|
543
845
|
};
|
|
544
846
|
}
|
|
545
847
|
|
|
848
|
+
export async function learningOutcomeStatus({ root = process.cwd(), scope = null, now = new Date() } = {}) {
|
|
849
|
+
const { learning, learningPath } = await loadLearning(root);
|
|
850
|
+
const runtimeScope = scope === null ? null : normalizeScope(scope);
|
|
851
|
+
const timestamp = date(now, "now");
|
|
852
|
+
const records = learning.candidates
|
|
853
|
+
.filter((candidate) => runtimeScope === null || scopeContains(candidate.scope, runtimeScope))
|
|
854
|
+
.map((candidate) => {
|
|
855
|
+
const outcomes = learning.outcomes.filter((item) => item.learningId === candidate.id);
|
|
856
|
+
const canary = candidate.promotion?.mode === "outcome-canary" ? candidate.promotion.canary : null;
|
|
857
|
+
const stale = canary?.status === "active" && new Date(canary.expiresAt).getTime() < new Date(timestamp).getTime();
|
|
858
|
+
return {
|
|
859
|
+
id: candidate.id,
|
|
860
|
+
kind: candidate.kind,
|
|
861
|
+
status: candidate.status,
|
|
862
|
+
conflictsWith: candidate.conflictsWith || [],
|
|
863
|
+
beforeReceipts: outcomes.filter((item) => item.phase === "before").length,
|
|
864
|
+
afterReceipts: outcomes.filter((item) => item.phase === "after").length,
|
|
865
|
+
canaryStatus: stale ? "stale" : (canary?.status || "not-applicable"),
|
|
866
|
+
expiresAt: canary?.expiresAt || null,
|
|
867
|
+
authority: "context-only"
|
|
868
|
+
};
|
|
869
|
+
});
|
|
870
|
+
return {
|
|
871
|
+
schema: "agentspine.learning-outcome-status/v1",
|
|
872
|
+
root: learning.root,
|
|
873
|
+
records,
|
|
874
|
+
learningPath,
|
|
875
|
+
authority: "context-only",
|
|
876
|
+
note: "Outcome status is context-only and never grants permissions, delegation, access, or policy exceptions."
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
546
880
|
export async function configureLearning({ root = process.cwd(), config = {}, now = new Date() }) {
|
|
547
881
|
if (!config || typeof config !== "object" || Array.isArray(config) || !Object.keys(config).length) {
|
|
548
882
|
throw new Error("config must change at least one learning setting");
|
|
549
883
|
}
|
|
550
|
-
const allowed = new Set([
|
|
884
|
+
const allowed = new Set([
|
|
885
|
+
"autoPromote", "minConfidence", "minEvidence", "maxContextItems", "minOutcomeReceipts",
|
|
886
|
+
"minImprovement", "regressionTolerance", "outcomeMaxAgeDays", "canaryReceipts", "canaryTtlDays"
|
|
887
|
+
]);
|
|
551
888
|
const unknown = Object.keys(config).filter((key) => !allowed.has(key));
|
|
552
889
|
if (unknown.length) throw new Error(`unsupported learning config: ${unknown.join(", ")}`);
|
|
553
890
|
const timestamp = date(now, "now");
|
|
@@ -560,6 +897,12 @@ export async function configureLearning({ root = process.cwd(), config = {}, now
|
|
|
560
897
|
if ("minConfidence" in config) state.config.minConfidence = number(config.minConfidence, "minConfidence", 0.5, 1);
|
|
561
898
|
if ("minEvidence" in config) state.config.minEvidence = integer(config.minEvidence, "minEvidence", 1, 10);
|
|
562
899
|
if ("maxContextItems" in config) state.config.maxContextItems = integer(config.maxContextItems, "maxContextItems", 1, 50);
|
|
900
|
+
if ("minOutcomeReceipts" in config) state.config.minOutcomeReceipts = integer(config.minOutcomeReceipts, "minOutcomeReceipts", 2, 10);
|
|
901
|
+
if ("minImprovement" in config) state.config.minImprovement = number(config.minImprovement, "minImprovement", 0, 1);
|
|
902
|
+
if ("regressionTolerance" in config) state.config.regressionTolerance = number(config.regressionTolerance, "regressionTolerance", 0, 1);
|
|
903
|
+
if ("outcomeMaxAgeDays" in config) state.config.outcomeMaxAgeDays = integer(config.outcomeMaxAgeDays, "outcomeMaxAgeDays", 1, 365);
|
|
904
|
+
if ("canaryReceipts" in config) state.config.canaryReceipts = integer(config.canaryReceipts, "canaryReceipts", 1, 10);
|
|
905
|
+
if ("canaryTtlDays" in config) state.config.canaryTtlDays = integer(config.canaryTtlDays, "canaryTtlDays", 1, 90);
|
|
563
906
|
if (!validConfig(state.config)) throw new Error("resulting learning configuration is invalid");
|
|
564
907
|
return { config: state.config, learningPath };
|
|
565
908
|
});
|
|
@@ -574,6 +917,7 @@ export async function deleteLearning({ root = process.cwd(), id }) {
|
|
|
574
917
|
}
|
|
575
918
|
const existed = Boolean(candidate);
|
|
576
919
|
state.candidates = state.candidates.filter((entry) => entry.id !== id);
|
|
920
|
+
state.outcomes = state.outcomes.filter((entry) => entry.learningId !== id);
|
|
577
921
|
state.history = state.history.filter((entry) => entry.recordId !== id && entry.value?.id !== id);
|
|
578
922
|
return { deleted: existed, id, learningPath };
|
|
579
923
|
});
|
|
@@ -584,6 +928,7 @@ export async function purgeLearningBySubject({ root = process.cwd(), subjectId }
|
|
|
584
928
|
return mutation(root, (state, _catalog, learningPath) => {
|
|
585
929
|
const ids = new Set(state.candidates.filter((entry) => entry.subjectId === subjectId).map((entry) => entry.id));
|
|
586
930
|
state.candidates = state.candidates.filter((entry) => entry.subjectId !== subjectId);
|
|
931
|
+
state.outcomes = state.outcomes.filter((entry) => !ids.has(entry.learningId));
|
|
587
932
|
state.history = state.history.filter((entry) => entry.subjectId !== subjectId && !ids.has(entry.recordId) && !ids.has(entry.value?.id));
|
|
588
933
|
return { deleted: ids.size, subjectId, learningPath };
|
|
589
934
|
});
|
|
@@ -611,6 +956,8 @@ export function learningFindings(learning, graph) {
|
|
|
611
956
|
findings.push(`invalid-evidence:${candidate.id}`);
|
|
612
957
|
}
|
|
613
958
|
if (candidate.privacy === "group" && (!groups.has(candidate.groupId) || !isGroupMember(graph, candidate.groupId, candidate.subjectId))) findings.push(`invalid-group:${candidate.id}`);
|
|
959
|
+
if (!candidate.scope || Object.keys(candidate.scope).some((field) => !SCOPE_FIELDS.includes(field))
|
|
960
|
+
|| Object.values(candidate.scope || {}).some((value) => value !== null && !ID_RE.test(value))) findings.push(`invalid-scope:${candidate.id}`);
|
|
614
961
|
if (candidate.status === "accepted") {
|
|
615
962
|
const manualProof = candidate.automatic === false
|
|
616
963
|
&& candidate.review?.decision === "accept" && candidate.review?.confirmedByUser === true;
|
|
@@ -626,10 +973,37 @@ export function learningFindings(learning, graph) {
|
|
|
626
973
|
&& candidate.confidence >= candidate.promotion?.minConfidence
|
|
627
974
|
&& candidate.promotion?.directness >= candidate.promotion?.minDirectness
|
|
628
975
|
&& distinctEvidence(candidate) >= candidate.promotion?.minEvidence
|
|
629
|
-
&& candidate.promotion?.evidenceCount >= candidate.promotion?.minEvidence)
|
|
976
|
+
&& candidate.promotion?.evidenceCount >= candidate.promotion?.minEvidence)
|
|
977
|
+
|| (OUTCOME_AUTO_KINDS.has(candidate.kind)
|
|
978
|
+
&& candidate.promotion?.mode === "outcome-canary"
|
|
979
|
+
&& candidate.requiresLocalReview === false
|
|
980
|
+
&& ["active", "validated"].includes(candidate.promotion?.canary?.status)
|
|
981
|
+
&& candidate.confidence >= candidate.promotion?.minConfidence
|
|
982
|
+
&& distinctEvidence(candidate) >= candidate.promotion?.minEvidence
|
|
983
|
+
&& candidate.promotion?.canary?.beforeReceipts?.length >= learning.config.minOutcomeReceipts));
|
|
630
984
|
if (!candidate.acceptedAt || (!manualProof && !automaticProof)) findings.push(`invalid-acceptance:${candidate.id}`);
|
|
985
|
+
if (candidate.promotion?.mode === "outcome-canary" && candidate.promotion?.canary?.status === "active"
|
|
986
|
+
&& new Date(candidate.promotion.canary.expiresAt).getTime() < Date.now()) findings.push(`stale-canary:${candidate.id}`);
|
|
631
987
|
}
|
|
632
988
|
}
|
|
989
|
+
const outcomeIds = new Set();
|
|
990
|
+
for (const receipt of learning.outcomes || []) {
|
|
991
|
+
const candidate = learning.candidates.find((item) => item.id === receipt.learningId);
|
|
992
|
+
const payload = outcomePayload(receipt);
|
|
993
|
+
const valid = receipt.schema === "agentspine.learning-outcome/v1" && ID_RE.test(receipt.id || "")
|
|
994
|
+
&& candidate && OUTCOME_PHASES.has(receipt.phase) && scopeContains(candidate.scope, receipt.scope)
|
|
995
|
+
&& SCOPE_FIELDS.every((field) => receipt.scope?.[field] === null || ID_RE.test(receipt.scope?.[field] || ""))
|
|
996
|
+
&& typeof receipt.metric?.name === "string" && receipt.metric.name.length > 0
|
|
997
|
+
&& METRIC_DIRECTIONS.has(receipt.metric?.direction)
|
|
998
|
+
&& Number.isFinite(receipt.metric?.value) && receipt.metric.value >= 0 && receipt.metric.value <= 1
|
|
999
|
+
&& Number.isInteger(receipt.metric?.blockingDefects) && receipt.metric.blockingDefects >= 0
|
|
1000
|
+
&& MEASUREMENT_KINDS.has(receipt.measurement?.kind) && ID_RE.test(receipt.measurement?.evaluatorId || "")
|
|
1001
|
+
&& (receipt.measurement?.sourceDigest === null || /^[a-f0-9]{64}$/.test(receipt.measurement?.sourceDigest || ""))
|
|
1002
|
+
&& receipt.authority === "context-only" && receipt.measurement?.authority === "context-only"
|
|
1003
|
+
&& Number.isFinite(new Date(receipt.measuredAt).getTime()) && receipt.digest === digest(payload);
|
|
1004
|
+
if (!valid || outcomeIds.has(receipt.id)) findings.push(`invalid-outcome:${receipt.id || "unknown"}`);
|
|
1005
|
+
outcomeIds.add(receipt.id);
|
|
1006
|
+
}
|
|
633
1007
|
for (const entry of learning.history) {
|
|
634
1008
|
const value = entry.value || {};
|
|
635
1009
|
const nested = [...(value.evidence || []), value.review, value.rollback, value.promotion].filter(Boolean);
|