@kontextmind/kxm 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.kxm/README.md +14 -0
  3. package/.kxm/assets/README.md +5 -0
  4. package/.kxm/assets/retrospectives/README.md +5 -0
  5. package/.kxm/config/README.md +5 -0
  6. package/.kxm/config/agents.json +43 -0
  7. package/.kxm/config/env.example +56 -0
  8. package/.kxm/config/update.example.yaml +9 -0
  9. package/.kxm/config/workflows/fix.json +160 -0
  10. package/.kxm/config/workflows/jira-development.json +116 -0
  11. package/.kxm/config/workflows/provenance-quorum.json +150 -0
  12. package/.kxm/config/workflows/v04-dogfood.json +72 -0
  13. package/CHANGELOG.md +465 -0
  14. package/LICENSE +21 -0
  15. package/README.md +306 -0
  16. package/SECURITY.md +72 -0
  17. package/docs/README.md +48 -0
  18. package/docs/agent-communication-envelopes-and-gates.md +553 -0
  19. package/docs/architecture.md +242 -0
  20. package/docs/assignment-runner.md +241 -0
  21. package/docs/configuration.md +361 -0
  22. package/docs/continuous-improvement.md +114 -0
  23. package/docs/getting-started.md +253 -0
  24. package/docs/kxm-handbook.md +1090 -0
  25. package/docs/operations.md +205 -0
  26. package/docs/provenance-gates.md +291 -0
  27. package/docs/skills.md +45 -0
  28. package/docs/templates/README.md +95 -0
  29. package/docs/templates/adr.md +88 -0
  30. package/docs/templates/architecture.md +120 -0
  31. package/docs/templates/bug-fix.md +109 -0
  32. package/docs/templates/feature.md +108 -0
  33. package/docs/templates/handoff.md +72 -0
  34. package/docs/templates/postmortem.md +77 -0
  35. package/docs/templates/research.md +100 -0
  36. package/docs/templates/review.md +85 -0
  37. package/docs/templates/runbook.md +73 -0
  38. package/docs/templates/test-plan.md +87 -0
  39. package/docs/templates/test-report.md +72 -0
  40. package/docs/test-matrix.md +121 -0
  41. package/docs/troubleshooting.md +249 -0
  42. package/docs/vnext/README.md +62 -0
  43. package/docs/vnext/architecture.md +185 -0
  44. package/docs/vnext/effects-and-recovery.md +172 -0
  45. package/docs/vnext/lifecycles.md +235 -0
  46. package/docs/vnext/migration.md +220 -0
  47. package/docs/vnext/routing.md +184 -0
  48. package/docs/vnext/synchronization.md +172 -0
  49. package/docs/vnext/terminology.md +240 -0
  50. package/docs/vnext/validation.md +335 -0
  51. package/docs/webhook-workflows.md +240 -0
  52. package/docs/workflow-guide.md +1150 -0
  53. package/examples/README.md +102 -0
  54. package/examples/provenance-workflow.json +40 -0
  55. package/examples/requester.ts +30 -0
  56. package/examples/reviewer-agent.ts +29 -0
  57. package/examples/roundtrip.ts +46 -0
  58. package/examples/vnext/.kxm/agents/coordinator.yaml +16 -0
  59. package/examples/vnext/.kxm/agents/critic-1.yaml +16 -0
  60. package/examples/vnext/.kxm/agents/critic-2.yaml +15 -0
  61. package/examples/vnext/.kxm/agents/critic-3.yaml +15 -0
  62. package/examples/vnext/.kxm/agents/implementer.yaml +15 -0
  63. package/examples/vnext/.kxm/agents/planner.yaml +13 -0
  64. package/examples/vnext/.kxm/agents/reproducer.yaml +15 -0
  65. package/examples/vnext/.kxm/agents/reviewer.yaml +15 -0
  66. package/examples/vnext/.kxm/gates.yaml +8 -0
  67. package/examples/vnext/.kxm/models/critic-claude.yaml +11 -0
  68. package/examples/vnext/.kxm/models/critic-gemini.yaml +11 -0
  69. package/examples/vnext/.kxm/models/critic-grok.yaml +12 -0
  70. package/examples/vnext/.kxm/models/implementation.yaml +14 -0
  71. package/examples/vnext/.kxm/models/primary.yaml +17 -0
  72. package/examples/vnext/.kxm/prices.yaml +111 -0
  73. package/examples/vnext/.kxm/project/env.yaml +7 -0
  74. package/examples/vnext/.kxm/project.yaml +32 -0
  75. package/examples/vnext/.kxm/repo/repo.yaml +8 -0
  76. package/examples/vnext/.kxm/workflows/default.yaml +92 -0
  77. package/examples/vnext/.kxm/workflows/fix.yaml +376 -0
  78. package/examples/vnext/.kxm/workflows/improve.yaml +57 -0
  79. package/examples/vnext/README.md +53 -0
  80. package/examples/vnext/records/assignment-result-recorded.json +63 -0
  81. package/examples/vnext/records/assignment-result.json +46 -0
  82. package/examples/vnext/records/context-candidate.json +42 -0
  83. package/examples/vnext/records/delivery-manifest.json +66 -0
  84. package/examples/vnext/records/effect-uncertainty-resolved-sync.json +67 -0
  85. package/examples/vnext/records/effect-uncertainty-resolved.json +62 -0
  86. package/examples/vnext/records/run-created.json +54 -0
  87. package/examples/vnext/records/sync-event.json +65 -0
  88. package/examples/vnext/repositories/api/.kxm/repo/env.yaml +7 -0
  89. package/examples/vnext/repositories/api/.kxm/repo/repo.yaml +8 -0
  90. package/examples/vnext/repositories/web/.kxm/repo/repo.yaml +8 -0
  91. package/examples/workflow-signal.ts +63 -0
  92. package/package.json +129 -0
  93. package/plugins/kxm/.claude-plugin/plugin.json +73 -0
  94. package/plugins/kxm/.mcp.json +19 -0
  95. package/plugins/kxm/README.md +93 -0
  96. package/plugins/kxm/dist/cli.js +42853 -0
  97. package/plugins/kxm/dist/client.js +416 -0
  98. package/plugins/kxm/dist/core.js +1823 -0
  99. package/plugins/kxm/dist/extension.js +3797 -0
  100. package/plugins/kxm/dist/mcp-server.js +17104 -0
  101. package/plugins/kxm/dist/runtime.js +23361 -0
  102. package/plugins/kxm/dist/server.js +13640 -0
  103. package/plugins/kxm/dist/vnext-runtime-supervisor.js +21109 -0
  104. package/plugins/kxm/package.json +12 -0
  105. package/plugins/kxm/skills/kxm/SKILL.md +97 -0
  106. package/plugins/kxm/skills/kxm/references/protocol.md +103 -0
  107. package/plugins/kxm/skills/kxm-session/SKILL.md +53 -0
  108. package/plugins/kxm/src/arbiter.ts +355 -0
  109. package/plugins/kxm/src/artifacts-exist.ts +62 -0
  110. package/plugins/kxm/src/autocomplete.ts +236 -0
  111. package/plugins/kxm/src/cli.ts +3707 -0
  112. package/plugins/kxm/src/client.ts +614 -0
  113. package/plugins/kxm/src/commands.ts +1063 -0
  114. package/plugins/kxm/src/config.ts +290 -0
  115. package/plugins/kxm/src/context/providers.ts +101 -0
  116. package/plugins/kxm/src/context-packet.ts +332 -0
  117. package/plugins/kxm/src/context.ts +499 -0
  118. package/plugins/kxm/src/core.ts +6 -0
  119. package/plugins/kxm/src/database.ts +563 -0
  120. package/plugins/kxm/src/diagnostics.ts +184 -0
  121. package/plugins/kxm/src/envelope.ts +118 -0
  122. package/plugins/kxm/src/extension.ts +895 -0
  123. package/plugins/kxm/src/external-effects.ts +299 -0
  124. package/plugins/kxm/src/github-watch.ts +255 -0
  125. package/plugins/kxm/src/hub-binding.ts +160 -0
  126. package/plugins/kxm/src/hub.ts +2502 -0
  127. package/plugins/kxm/src/improve.ts +383 -0
  128. package/plugins/kxm/src/inbox.ts +10 -0
  129. package/plugins/kxm/src/kxm-install-kind.ts +113 -0
  130. package/plugins/kxm/src/kxm-update-config.ts +39 -0
  131. package/plugins/kxm/src/kxm-update.ts +238 -0
  132. package/plugins/kxm/src/local-snapshot.ts +406 -0
  133. package/plugins/kxm/src/logger.ts +198 -0
  134. package/plugins/kxm/src/mcp-server.ts +143 -0
  135. package/plugins/kxm/src/memory.ts +385 -0
  136. package/plugins/kxm/src/nous-pi.ts +287 -0
  137. package/plugins/kxm/src/nous-provider.ts +729 -0
  138. package/plugins/kxm/src/price-calc.ts +87 -0
  139. package/plugins/kxm/src/prices.ts +121 -0
  140. package/plugins/kxm/src/protocol.ts +172 -0
  141. package/plugins/kxm/src/recovery.ts +211 -0
  142. package/plugins/kxm/src/redact.ts +26 -0
  143. package/plugins/kxm/src/retrospective.ts +400 -0
  144. package/plugins/kxm/src/routing.ts +830 -0
  145. package/plugins/kxm/src/runtime.ts +9 -0
  146. package/plugins/kxm/src/server.ts +117 -0
  147. package/plugins/kxm/src/session-work.ts +571 -0
  148. package/plugins/kxm/src/session.ts +184 -0
  149. package/plugins/kxm/src/skills.ts +535 -0
  150. package/plugins/kxm/src/state.ts +326 -0
  151. package/plugins/kxm/src/store.ts +637 -0
  152. package/plugins/kxm/src/studio-layout.ts +268 -0
  153. package/plugins/kxm/src/suggest.ts +162 -0
  154. package/plugins/kxm/src/task-manager.ts +244 -0
  155. package/plugins/kxm/src/telemetry.ts +116 -0
  156. package/plugins/kxm/src/tui.ts +1046 -0
  157. package/plugins/kxm/src/vnext-bindings.ts +403 -0
  158. package/plugins/kxm/src/vnext-config.ts +1646 -0
  159. package/plugins/kxm/src/vnext-engine-artifacts.ts +86 -0
  160. package/plugins/kxm/src/vnext-engine-command.ts +533 -0
  161. package/plugins/kxm/src/vnext-engine-compile.ts +722 -0
  162. package/plugins/kxm/src/vnext-engine-evidence.ts +273 -0
  163. package/plugins/kxm/src/vnext-engine-fold.ts +1400 -0
  164. package/plugins/kxm/src/vnext-engine-gate-records.ts +583 -0
  165. package/plugins/kxm/src/vnext-engine-plan.ts +717 -0
  166. package/plugins/kxm/src/vnext-engine.ts +2458 -0
  167. package/plugins/kxm/src/vnext-gate-hash.ts +10 -0
  168. package/plugins/kxm/src/vnext-harness.ts +1142 -0
  169. package/plugins/kxm/src/vnext-init.ts +430 -0
  170. package/plugins/kxm/src/vnext-migrate.ts +1848 -0
  171. package/plugins/kxm/src/vnext-oneshot-producer.ts +424 -0
  172. package/plugins/kxm/src/vnext-permission.ts +936 -0
  173. package/plugins/kxm/src/vnext-pi-producer.ts +628 -0
  174. package/plugins/kxm/src/vnext-repair.ts +1094 -0
  175. package/plugins/kxm/src/vnext-runtime-owner.ts +320 -0
  176. package/plugins/kxm/src/vnext-runtime-store.ts +1560 -0
  177. package/plugins/kxm/src/vnext-runtime-supervisor.ts +586 -0
  178. package/plugins/kxm/src/vnext-runtime.ts +663 -0
  179. package/plugins/kxm/src/vnext-template.ts +247 -0
  180. package/plugins/kxm/src/wiki.ts +313 -0
  181. package/plugins/kxm/src/workflow.ts +1548 -0
  182. package/schemas/vnext/README.md +46 -0
  183. package/schemas/vnext/agent.schema.json +40 -0
  184. package/schemas/vnext/assignment-result.schema.json +66 -0
  185. package/schemas/vnext/backup-manifest.schema.json +89 -0
  186. package/schemas/vnext/candidate.schema.json +109 -0
  187. package/schemas/vnext/common.schema.json +422 -0
  188. package/schemas/vnext/context-candidate.schema.json +76 -0
  189. package/schemas/vnext/context-packet.schema.json +192 -0
  190. package/schemas/vnext/delivery-manifest.schema.json +159 -0
  191. package/schemas/vnext/environment.schema.json +66 -0
  192. package/schemas/vnext/gate-registry.schema.json +109 -0
  193. package/schemas/vnext/handoff-manifest.schema.json +146 -0
  194. package/schemas/vnext/init-operation.schema.json +61 -0
  195. package/schemas/vnext/local-repository-bindings.schema.json +30 -0
  196. package/schemas/vnext/memory-record.schema.json +45 -0
  197. package/schemas/vnext/migration-decision.schema.json +26 -0
  198. package/schemas/vnext/migration-plan.schema.json +123 -0
  199. package/schemas/vnext/migration-receipt.schema.json +52 -0
  200. package/schemas/vnext/model.schema.json +42 -0
  201. package/schemas/vnext/permission-diff.schema.json +57 -0
  202. package/schemas/vnext/prices.schema.json +115 -0
  203. package/schemas/vnext/project.schema.json +85 -0
  204. package/schemas/vnext/repository.schema.json +24 -0
  205. package/schemas/vnext/run-event.schema.json +460 -0
  206. package/schemas/vnext/session-brief.schema.json +153 -0
  207. package/schemas/vnext/sync-event.schema.json +234 -0
  208. package/schemas/vnext/template-provenance.schema.json +38 -0
  209. package/schemas/vnext/workflow.schema.json +248 -0
  210. package/scripts/assignment-run.d.mts +354 -0
  211. package/scripts/assignment-run.mjs +4451 -0
  212. package/scripts/build-runtime.mjs +56 -0
  213. package/scripts/check-generated.mjs +77 -0
  214. package/scripts/check-versions.mjs +34 -0
  215. package/scripts/emit-codex-artifacts.d.mts +9 -0
  216. package/scripts/emit-codex-artifacts.mjs +91 -0
  217. package/scripts/harness-run.d.mts +83 -0
  218. package/scripts/harness-run.mjs +2095 -0
  219. package/scripts/kxm-hub.mjs +105 -0
  220. package/scripts/kxm-publish-npm.mjs +327 -0
  221. package/scripts/kxm-release-github.mjs +472 -0
  222. package/scripts/kxm-runtime-supervisor.mjs +7 -0
  223. package/scripts/kxm-worker.mjs +1127 -0
  224. package/scripts/kxm.mjs +27 -0
  225. package/scripts/roster-policy.d.mts +20 -0
  226. package/scripts/roster-policy.mjs +161 -0
  227. package/scripts/smoke-multi-pi.mjs +479 -0
@@ -0,0 +1,326 @@
1
+ import { ProtocolError, newId, nowIso } from "./protocol.ts";
2
+ import { parseContextItem, type ContextItem } from "./context.ts";
3
+ import type { StateChangeProposal, StateProvider } from "./context/providers.ts";
4
+ import type { MeshStore } from "./store.ts";
5
+
6
+ /**
7
+ * Authoritative temporal project state (v0.5, issue #33).
8
+ *
9
+ * State items are ContextItems of kind `state` addressed by `stateKey`. The
10
+ * lifecycle is explicit: `proposed` → `current` → `superseded` (or
11
+ * `rejected`). Queries are deterministic and historical (`asOf`), and every
12
+ * mutation is evidence-bound and audited through durable records.
13
+ *
14
+ * The native implementation persists through the MeshStore SQLite context
15
+ * table; the `StateProvider` seam (issue #31) keeps this replaceable by a
16
+ * temporal-graph adapter without changing KXM's public API.
17
+ */
18
+
19
+ export type StateLifecycle = "current" | "superseded" | "proposed" | "rejected";
20
+
21
+ /** Keys that are explicitly declared set-valued: more than one current item
22
+ * may exist at a time and callers must use `currentSet` instead of `get`. */
23
+ export type SetValuedStateKeys = ReadonlySet<string>;
24
+
25
+ export const MAX_STATE_EVIDENCE_REFS = 32;
26
+
27
+ function timestampMs(value: string | undefined): number {
28
+ if (value === undefined) return Number.NEGATIVE_INFINITY;
29
+ const parsed = Date.parse(value);
30
+ return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY;
31
+ }
32
+
33
+ /** True when the item's validity window contains `atMs`. Items without
34
+ * `validFrom` are treated as valid since the beginning of time; items without
35
+ * `validUntil` remain valid indefinitely. */
36
+ export function stateActiveAt(item: ContextItem, atMs: number): boolean {
37
+ if (item.kind !== "state") return false;
38
+ if (item.status !== "current" && item.status !== "superseded") return false;
39
+ const from = timestampMs(item.validFrom);
40
+ const until = item.validUntil === undefined ? Number.POSITIVE_INFINITY : timestampMs(item.validUntil);
41
+ return from <= atMs && atMs < until;
42
+ }
43
+
44
+ /** Deterministic winner among active items for one key: greatest validFrom,
45
+ * then greatest id. Identical inputs always produce identical results. */
46
+ export function latestActive(items: ContextItem[]): ContextItem | undefined {
47
+ let best: ContextItem | undefined;
48
+ for (const item of items) {
49
+ if (best === undefined
50
+ || timestampMs(item.validFrom) > timestampMs(best.validFrom)
51
+ || (timestampMs(item.validFrom) === timestampMs(best.validFrom) && item.id > best.id)) {
52
+ best = item;
53
+ }
54
+ }
55
+ return best;
56
+ }
57
+
58
+ /** The item that directly superseded `id`, if any. */
59
+ export function findSuperseder(items: ContextItem[], id: string): ContextItem | undefined {
60
+ return items.find((item) => (item.supersedes ?? []).includes(id));
61
+ }
62
+
63
+ export interface StateContradiction {
64
+ project: string;
65
+ stateKey: string;
66
+ /** More than one current item for a single-valued key. */
67
+ competingCurrentIds: string[];
68
+ /** Competing unresolved proposals for the same key. */
69
+ competingProposalIds: string[];
70
+ }
71
+
72
+ /** Detect contradictions in a project's state: single-valued keys with more
73
+ * than one current item, and keys with competing unresolved proposals. Open
74
+ * contradictions are surfaced (wiki, arbiter) — never silently resolved. */
75
+ export function detectStateContradictions(
76
+ project: string,
77
+ items: ContextItem[],
78
+ setValuedKeys: SetValuedStateKeys = new Set(),
79
+ ): StateContradiction[] {
80
+ const byKey = new Map<string, ContextItem[]>();
81
+ for (const item of items) {
82
+ if (item.kind !== "state" || item.project !== project) continue;
83
+ const key = item.stateKey ?? "";
84
+ const bucket = byKey.get(key) ?? [];
85
+ bucket.push(item);
86
+ byKey.set(key, bucket);
87
+ }
88
+ const contradictions: StateContradiction[] = [];
89
+ for (const [stateKey, bucket] of [...byKey.entries()].sort(([left], [right]) => left.localeCompare(right))) {
90
+ if (setValuedKeys.has(stateKey)) continue;
91
+ const competingCurrentIds = bucket
92
+ .filter((item) => item.status === "current")
93
+ .map((item) => item.id)
94
+ .sort();
95
+ const competingProposalIds = bucket
96
+ .filter((item) => item.status === "proposed")
97
+ .map((item) => item.id)
98
+ .sort();
99
+ if (competingCurrentIds.length > 1 || competingProposalIds.length > 1) {
100
+ contradictions.push({ project, stateKey, competingCurrentIds, competingProposalIds });
101
+ }
102
+ }
103
+ return contradictions;
104
+ }
105
+
106
+ /** Parse a state item from untrusted input. State items must carry a key and
107
+ * an explicit lifecycle status. */
108
+ export function parseStateItem(value: unknown): ContextItem {
109
+ const item = parseContextItem(value);
110
+ if (item.kind !== "state") {
111
+ throw new ProtocolError(400, "state layer accepts only state items", "invalid_state_item");
112
+ }
113
+ return item;
114
+ }
115
+
116
+ /** Native SQLite-backed temporal state provider. Agents may propose; only
117
+ * the control plane (hub admin routes) may promote, and never the proposal
118
+ * author. All mutations are durable and evidence-bound. */
119
+ export class NativeStateProvider implements StateProvider {
120
+ readonly name = "native-sqlite";
121
+ private readonly store: MeshStore;
122
+ private readonly now: () => string;
123
+ private readonly setValuedKeys: SetValuedStateKeys;
124
+
125
+ constructor(store: MeshStore, options: { now?: () => string; setValuedKeys?: SetValuedStateKeys } = {}) {
126
+ this.store = store;
127
+ this.now = options.now ?? nowIso;
128
+ this.setValuedKeys = options.setValuedKeys ?? new Set();
129
+ }
130
+
131
+ private projectState(project: string): ContextItem[] {
132
+ return this.store.listContextItems(project, ["state"]);
133
+ }
134
+
135
+ private itemsForKey(project: string, key: string): ContextItem[] {
136
+ return this.projectState(project).filter((item) => item.stateKey === key);
137
+ }
138
+
139
+ /** Current value for a key at a point in time. Superseded values never
140
+ * appear as current: historical queries return the item that *was* current
141
+ * at `asOf` via its validity window. Fails closed when a single-valued key
142
+ * holds competing current items. */
143
+ async get(project: string, key: string, asOf?: string): Promise<ContextItem | null> {
144
+ const scopedProject = requireNonEmpty(project, "project");
145
+ const scopedKey = requireNonEmpty(key, "key");
146
+ if (this.setValuedKeys.has(scopedKey)) {
147
+ throw new ProtocolError(
148
+ 400,
149
+ `state key ${scopedKey} is set-valued; use currentSet`,
150
+ "state_key_set_valued",
151
+ );
152
+ }
153
+ const atMs = asOf === undefined
154
+ ? Date.parse(this.now())
155
+ : requireIso(asOf, "asOf");
156
+ const active = this.itemsForKey(scopedProject, scopedKey).filter((item) => stateActiveAt(item, atMs));
157
+ const currents = active.filter((item) => item.status === "current");
158
+ if (currents.length > 1) {
159
+ throw new ProtocolError(
160
+ 409,
161
+ `state key ${scopedKey} has competing current items; resolve the contradiction first`,
162
+ "state_contradiction",
163
+ );
164
+ }
165
+ const winner = currents.length === 1 ? currents[0] : latestActive(active);
166
+ return winner ?? null;
167
+ }
168
+
169
+ /** All current items for a set-valued key. */
170
+ async currentSet(project: string, key: string): Promise<ContextItem[]> {
171
+ const scopedProject = requireNonEmpty(project, "project");
172
+ const scopedKey = requireNonEmpty(key, "key");
173
+ if (!this.setValuedKeys.has(scopedKey)) {
174
+ throw new ProtocolError(400, `state key ${scopedKey} is single-valued`, "state_key_single_valued");
175
+ }
176
+ return this.itemsForKey(scopedProject, scopedKey)
177
+ .filter((item) => item.status === "current")
178
+ .sort((left, right) => left.id.localeCompare(right.id));
179
+ }
180
+
181
+ /** Record a proposal. Proposing changes nothing until an authorized,
182
+ * evidence-bound promotion runs. Returns the durable proposal item ID. */
183
+ async propose(change: StateChangeProposal): Promise<string> {
184
+ if (change?.schema !== "kxm.state-change-proposal.v1") {
185
+ throw new ProtocolError(400, "invalid state change proposal schema", "invalid_state_proposal");
186
+ }
187
+ const project = requireNonEmpty(change.project, "proposal project");
188
+ const key = requireNonEmpty(change.key, "proposal key");
189
+ const evidenceRefs = boundedRefs(change.evidenceRefs, "proposal evidenceRefs");
190
+ const proposal: ContextItem = parseStateItem({
191
+ id: newId("ctx"),
192
+ kind: "state",
193
+ project,
194
+ summary: change.summary,
195
+ provenance: {
196
+ sourceType: change.proposedBy.startsWith("agent_") ? "peer" : "human",
197
+ sourceRef: `proposed-by:${change.proposedBy}`,
198
+ },
199
+ authority: change.authority,
200
+ confidence: change.confidence,
201
+ stateKey: key,
202
+ status: "proposed",
203
+ validFrom: this.now(),
204
+ evidenceRefs,
205
+ ...(change.supersedes ? { supersedes: boundedRefs(change.supersedes, "proposal supersedes") } : {}),
206
+ });
207
+ this.store.saveContextItem(proposal);
208
+ return proposal.id;
209
+ }
210
+
211
+ /** Promote a proposal to current with durable evidence. The promoter must
212
+ * differ from the proposal author (agents may propose but never silently
213
+ * promote). Superseded previous values get an explicit validity window so
214
+ * historical queries stay deterministic. */
215
+ async promote(proposalId: string, evidence: string[], promotedBy: string): Promise<ContextItem> {
216
+ const id = requireNonEmpty(proposalId, "proposalId");
217
+ const promoter = requireNonEmpty(promotedBy, "promotedBy");
218
+ const evidenceRefs = boundedRefs(evidence, "promotion evidence");
219
+ const proposal = this.store.getContextItem(id);
220
+ if (!proposal || proposal.kind !== "state") {
221
+ throw new ProtocolError(404, `state proposal ${id} not found`, "state_proposal_not_found");
222
+ }
223
+ if (proposal.status !== "proposed") {
224
+ throw new ProtocolError(
225
+ 400,
226
+ `state proposal ${id} already reached lifecycle state ${proposal.status}`,
227
+ "state_proposal_not_promotable",
228
+ );
229
+ }
230
+ const proposedBy = proposal.provenance.sourceRef?.startsWith("proposed-by:")
231
+ ? proposal.provenance.sourceRef.slice("proposed-by:".length)
232
+ : undefined;
233
+ if (proposedBy === promoter) {
234
+ throw new ProtocolError(
235
+ 400,
236
+ "the author of a state proposal cannot promote it",
237
+ "state_promotion_invalid",
238
+ );
239
+ }
240
+ const now = this.now();
241
+ const key = proposal.stateKey ?? "";
242
+ if (!key) {
243
+ throw new ProtocolError(400, "state proposal has no stateKey", "state_promotion_invalid");
244
+ }
245
+ // Capture the identities that hold current authority *before* stamping,
246
+ // so the supersession graph reflects who was actually superseded.
247
+ let supersededIds: string[] = [];
248
+ if (!this.setValuedKeys.has(key)) {
249
+ const atMs = Date.parse(now);
250
+ const supersededItems = this.itemsForKey(proposal.project, key)
251
+ .filter((item) => item.status === "current" && stateActiveAt(item, atMs));
252
+ supersededIds = supersededItems.map((item) => item.id);
253
+ for (const item of supersededItems) {
254
+ this.store.saveContextItem({
255
+ ...item,
256
+ status: "superseded",
257
+ validUntil: now,
258
+ });
259
+ }
260
+ }
261
+ const promoted: ContextItem = parseStateItem({
262
+ ...proposal,
263
+ id: newId("ctx"),
264
+ status: "current",
265
+ validFrom: now,
266
+ validUntil: undefined,
267
+ supersedes: [...new Set([...(proposal.supersedes ?? []), ...supersededIds])].sort(),
268
+ evidenceRefs: [...new Set([...(proposal.evidenceRefs ?? []), ...evidenceRefs])].sort(),
269
+ provenance: {
270
+ ...proposal.provenance,
271
+ sourceRef: `promoted-by:${promoter}`,
272
+ },
273
+ });
274
+ // Resolve the proposal record itself: it has been consumed by promotion.
275
+ this.store.saveContextItem({ ...proposal, status: "rejected", validUntil: now });
276
+ this.store.saveContextItem(promoted);
277
+ return promoted;
278
+ }
279
+
280
+ /** Which item superseded `id`, if any. */
281
+ async supersededBy(project: string, id: string): Promise<ContextItem | null> {
282
+ const scopedProject = requireNonEmpty(project, "project");
283
+ const superseder = findSuperseder(this.projectState(scopedProject), id);
284
+ return superseder ?? null;
285
+ }
286
+
287
+ /** Audit trail for one key: proposals, promotions, and supersessions in
288
+ * deterministic order. */
289
+ stateHistory(project: string, key: string): ContextItem[] {
290
+ return this.itemsForKey(project, key)
291
+ .sort((left, right) =>
292
+ timestampMs(left.validFrom) - timestampMs(right.validFrom) || left.id.localeCompare(right.id),
293
+ );
294
+ }
295
+
296
+ contradictions(): StateContradiction[] {
297
+ const projects = [...new Set([...this.store.contextItems.values()].map((item) => item.project))];
298
+ return projects.flatMap((project) => detectStateContradictions(project, this.projectState(project), this.setValuedKeys));
299
+ }
300
+ }
301
+
302
+ function requireNonEmpty(value: string, field: string): string {
303
+ if (typeof value !== "string" || !value.trim()) {
304
+ throw new ProtocolError(400, `${field} cannot be empty`, "invalid_state_request");
305
+ }
306
+ return value.trim();
307
+ }
308
+
309
+ function requireIso(value: string, field: string): number {
310
+ const parsed = Date.parse(value);
311
+ if (!Number.isFinite(parsed)) {
312
+ throw new ProtocolError(400, `${field} must be an ISO-8601 timestamp`, "invalid_state_request");
313
+ }
314
+ return parsed;
315
+ }
316
+
317
+ function boundedRefs(value: string[], field: string): string[] {
318
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_STATE_EVIDENCE_REFS) {
319
+ throw new ProtocolError(
320
+ 400,
321
+ `${field} must contain between 1 and ${MAX_STATE_EVIDENCE_REFS} references`,
322
+ "state_promotion_invalid",
323
+ );
324
+ }
325
+ return value.map((ref) => requireNonEmpty(ref, `${field} entry`));
326
+ }