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.
Files changed (58) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LIESMICH.txt +4 -2
  3. package/README.md +4 -2
  4. package/agent-spine-plugin/.claude-plugin/marketplace.json +1 -1
  5. package/agent-spine-plugin/.claude-plugin/plugin.json +1 -1
  6. package/agent-spine-plugin/.codex-plugin/plugin.json +1 -2
  7. package/agent-spine-plugin/CHANGELOG.md +81 -14
  8. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  9. package/agent-spine-plugin/README.md +6 -4
  10. package/agent-spine-plugin/SECURITY.md +47 -0
  11. package/agent-spine-plugin/blun.plugin.json +2 -2
  12. package/agent-spine-plugin/docs/acceptance.md +2 -2
  13. package/agent-spine-plugin/docs/architecture.md +1 -1
  14. package/agent-spine-plugin/docs/gateway-runtime.md +2 -2
  15. package/agent-spine-plugin/docs/host-integration.md +2 -2
  16. package/agent-spine-plugin/docs/learning.md +37 -2
  17. package/agent-spine-plugin/docs/preflight-recall.md +1 -1
  18. package/agent-spine-plugin/docs/quality-gates.md +1 -1
  19. package/agent-spine-plugin/docs/relationships.md +1 -1
  20. package/agent-spine-plugin/docs/session-briefing.md +1 -1
  21. package/agent-spine-plugin/docs/source-roots.md +1 -1
  22. package/agent-spine-plugin/hooks/codex.json +1 -1
  23. package/agent-spine-plugin/hooks/version.json +1 -1
  24. package/agent-spine-plugin/package.json +1 -1
  25. package/agent-spine-plugin/scripts/check-hosts.js +2 -2
  26. package/agent-spine-plugin/skills/agent-spine/SKILL.md +3 -3
  27. package/agent-spine-plugin/src/cli.js +69 -5
  28. package/agent-spine-plugin/src/hook.js +41 -23
  29. package/agent-spine-plugin/src/index.js +2 -1
  30. package/agent-spine-plugin/src/lib/acceptance.js +2 -3
  31. package/agent-spine-plugin/src/lib/attention.js +4 -4
  32. package/agent-spine-plugin/src/lib/audit.js +12 -3
  33. package/agent-spine-plugin/src/lib/authentication.js +4 -2
  34. package/agent-spine-plugin/src/lib/briefing.js +20 -8
  35. package/agent-spine-plugin/src/lib/catalog.js +28 -3
  36. package/agent-spine-plugin/src/lib/channel-runtime.js +2 -2
  37. package/agent-spine-plugin/src/lib/continuity.js +18 -13
  38. package/agent-spine-plugin/src/lib/documents.js +23 -5
  39. package/agent-spine-plugin/src/lib/feed-transport.js +4 -2
  40. package/agent-spine-plugin/src/lib/graph.js +47 -12
  41. package/agent-spine-plugin/src/lib/learning.js +422 -48
  42. package/agent-spine-plugin/src/lib/paths.js +50 -1
  43. package/agent-spine-plugin/src/lib/persona-runtime.js +20 -14
  44. package/agent-spine-plugin/src/lib/preflight.js +50 -26
  45. package/agent-spine-plugin/src/lib/source-roots.js +38 -7
  46. package/agent-spine-plugin/src/mcp.js +50 -4
  47. package/agent-spine-plugin/src/version.js +1 -1
  48. package/bin/telegram-approval-relay.cjs +2 -0
  49. package/blun.mjs +111 -8
  50. package/package.json +8 -2
  51. package/scripts/check-approval-observability-regression.js +111 -0
  52. package/scripts/check-bundled-agent-spine-regression.js +48 -0
  53. package/scripts/check-session-picker-resume-metrics-regression.js +97 -0
  54. package/scripts/check-session-start-hook-context-regression.js +54 -4
  55. package/scripts/check-shell-terminal-isolation-regression.js +81 -0
  56. package/scripts/check-slash-escape-regression.js +89 -0
  57. package/scripts/check-telegram-loop-exactly-once-regression.js +71 -0
  58. 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 { autoPromote: false, minConfidence: 0.85, minEvidence: 2, maxContextItems: 12 };
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
- return value;
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 || superseded.groupId !== groupId)) {
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({ root = process.cwd(), id, evidence, now = new Date() }) {
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
- 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
- }));
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
- 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 };
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(["autoPromote", "minConfidence", "minEvidence", "maxContextItems"]);
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);