@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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.kxm/README.md +14 -0
- package/.kxm/assets/README.md +5 -0
- package/.kxm/assets/retrospectives/README.md +5 -0
- package/.kxm/config/README.md +5 -0
- package/.kxm/config/agents.json +43 -0
- package/.kxm/config/env.example +56 -0
- package/.kxm/config/update.example.yaml +9 -0
- package/.kxm/config/workflows/fix.json +160 -0
- package/.kxm/config/workflows/jira-development.json +116 -0
- package/.kxm/config/workflows/provenance-quorum.json +150 -0
- package/.kxm/config/workflows/v04-dogfood.json +72 -0
- package/CHANGELOG.md +465 -0
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/SECURITY.md +72 -0
- package/docs/README.md +48 -0
- package/docs/agent-communication-envelopes-and-gates.md +553 -0
- package/docs/architecture.md +242 -0
- package/docs/assignment-runner.md +241 -0
- package/docs/configuration.md +361 -0
- package/docs/continuous-improvement.md +114 -0
- package/docs/getting-started.md +253 -0
- package/docs/kxm-handbook.md +1090 -0
- package/docs/operations.md +205 -0
- package/docs/provenance-gates.md +291 -0
- package/docs/skills.md +45 -0
- package/docs/templates/README.md +95 -0
- package/docs/templates/adr.md +88 -0
- package/docs/templates/architecture.md +120 -0
- package/docs/templates/bug-fix.md +109 -0
- package/docs/templates/feature.md +108 -0
- package/docs/templates/handoff.md +72 -0
- package/docs/templates/postmortem.md +77 -0
- package/docs/templates/research.md +100 -0
- package/docs/templates/review.md +85 -0
- package/docs/templates/runbook.md +73 -0
- package/docs/templates/test-plan.md +87 -0
- package/docs/templates/test-report.md +72 -0
- package/docs/test-matrix.md +121 -0
- package/docs/troubleshooting.md +249 -0
- package/docs/vnext/README.md +62 -0
- package/docs/vnext/architecture.md +185 -0
- package/docs/vnext/effects-and-recovery.md +172 -0
- package/docs/vnext/lifecycles.md +235 -0
- package/docs/vnext/migration.md +220 -0
- package/docs/vnext/routing.md +184 -0
- package/docs/vnext/synchronization.md +172 -0
- package/docs/vnext/terminology.md +240 -0
- package/docs/vnext/validation.md +335 -0
- package/docs/webhook-workflows.md +240 -0
- package/docs/workflow-guide.md +1150 -0
- package/examples/README.md +102 -0
- package/examples/provenance-workflow.json +40 -0
- package/examples/requester.ts +30 -0
- package/examples/reviewer-agent.ts +29 -0
- package/examples/roundtrip.ts +46 -0
- package/examples/vnext/.kxm/agents/coordinator.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-1.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-2.yaml +15 -0
- package/examples/vnext/.kxm/agents/critic-3.yaml +15 -0
- package/examples/vnext/.kxm/agents/implementer.yaml +15 -0
- package/examples/vnext/.kxm/agents/planner.yaml +13 -0
- package/examples/vnext/.kxm/agents/reproducer.yaml +15 -0
- package/examples/vnext/.kxm/agents/reviewer.yaml +15 -0
- package/examples/vnext/.kxm/gates.yaml +8 -0
- package/examples/vnext/.kxm/models/critic-claude.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-gemini.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-grok.yaml +12 -0
- package/examples/vnext/.kxm/models/implementation.yaml +14 -0
- package/examples/vnext/.kxm/models/primary.yaml +17 -0
- package/examples/vnext/.kxm/prices.yaml +111 -0
- package/examples/vnext/.kxm/project/env.yaml +7 -0
- package/examples/vnext/.kxm/project.yaml +32 -0
- package/examples/vnext/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/.kxm/workflows/default.yaml +92 -0
- package/examples/vnext/.kxm/workflows/fix.yaml +376 -0
- package/examples/vnext/.kxm/workflows/improve.yaml +57 -0
- package/examples/vnext/README.md +53 -0
- package/examples/vnext/records/assignment-result-recorded.json +63 -0
- package/examples/vnext/records/assignment-result.json +46 -0
- package/examples/vnext/records/context-candidate.json +42 -0
- package/examples/vnext/records/delivery-manifest.json +66 -0
- package/examples/vnext/records/effect-uncertainty-resolved-sync.json +67 -0
- package/examples/vnext/records/effect-uncertainty-resolved.json +62 -0
- package/examples/vnext/records/run-created.json +54 -0
- package/examples/vnext/records/sync-event.json +65 -0
- package/examples/vnext/repositories/api/.kxm/repo/env.yaml +7 -0
- package/examples/vnext/repositories/api/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/repositories/web/.kxm/repo/repo.yaml +8 -0
- package/examples/workflow-signal.ts +63 -0
- package/package.json +129 -0
- package/plugins/kxm/.claude-plugin/plugin.json +73 -0
- package/plugins/kxm/.mcp.json +19 -0
- package/plugins/kxm/README.md +93 -0
- package/plugins/kxm/dist/cli.js +42853 -0
- package/plugins/kxm/dist/client.js +416 -0
- package/plugins/kxm/dist/core.js +1823 -0
- package/plugins/kxm/dist/extension.js +3797 -0
- package/plugins/kxm/dist/mcp-server.js +17104 -0
- package/plugins/kxm/dist/runtime.js +23361 -0
- package/plugins/kxm/dist/server.js +13640 -0
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +21109 -0
- package/plugins/kxm/package.json +12 -0
- package/plugins/kxm/skills/kxm/SKILL.md +97 -0
- package/plugins/kxm/skills/kxm/references/protocol.md +103 -0
- package/plugins/kxm/skills/kxm-session/SKILL.md +53 -0
- package/plugins/kxm/src/arbiter.ts +355 -0
- package/plugins/kxm/src/artifacts-exist.ts +62 -0
- package/plugins/kxm/src/autocomplete.ts +236 -0
- package/plugins/kxm/src/cli.ts +3707 -0
- package/plugins/kxm/src/client.ts +614 -0
- package/plugins/kxm/src/commands.ts +1063 -0
- package/plugins/kxm/src/config.ts +290 -0
- package/plugins/kxm/src/context/providers.ts +101 -0
- package/plugins/kxm/src/context-packet.ts +332 -0
- package/plugins/kxm/src/context.ts +499 -0
- package/plugins/kxm/src/core.ts +6 -0
- package/plugins/kxm/src/database.ts +563 -0
- package/plugins/kxm/src/diagnostics.ts +184 -0
- package/plugins/kxm/src/envelope.ts +118 -0
- package/plugins/kxm/src/extension.ts +895 -0
- package/plugins/kxm/src/external-effects.ts +299 -0
- package/plugins/kxm/src/github-watch.ts +255 -0
- package/plugins/kxm/src/hub-binding.ts +160 -0
- package/plugins/kxm/src/hub.ts +2502 -0
- package/plugins/kxm/src/improve.ts +383 -0
- package/plugins/kxm/src/inbox.ts +10 -0
- package/plugins/kxm/src/kxm-install-kind.ts +113 -0
- package/plugins/kxm/src/kxm-update-config.ts +39 -0
- package/plugins/kxm/src/kxm-update.ts +238 -0
- package/plugins/kxm/src/local-snapshot.ts +406 -0
- package/plugins/kxm/src/logger.ts +198 -0
- package/plugins/kxm/src/mcp-server.ts +143 -0
- package/plugins/kxm/src/memory.ts +385 -0
- package/plugins/kxm/src/nous-pi.ts +287 -0
- package/plugins/kxm/src/nous-provider.ts +729 -0
- package/plugins/kxm/src/price-calc.ts +87 -0
- package/plugins/kxm/src/prices.ts +121 -0
- package/plugins/kxm/src/protocol.ts +172 -0
- package/plugins/kxm/src/recovery.ts +211 -0
- package/plugins/kxm/src/redact.ts +26 -0
- package/plugins/kxm/src/retrospective.ts +400 -0
- package/plugins/kxm/src/routing.ts +830 -0
- package/plugins/kxm/src/runtime.ts +9 -0
- package/plugins/kxm/src/server.ts +117 -0
- package/plugins/kxm/src/session-work.ts +571 -0
- package/plugins/kxm/src/session.ts +184 -0
- package/plugins/kxm/src/skills.ts +535 -0
- package/plugins/kxm/src/state.ts +326 -0
- package/plugins/kxm/src/store.ts +637 -0
- package/plugins/kxm/src/studio-layout.ts +268 -0
- package/plugins/kxm/src/suggest.ts +162 -0
- package/plugins/kxm/src/task-manager.ts +244 -0
- package/plugins/kxm/src/telemetry.ts +116 -0
- package/plugins/kxm/src/tui.ts +1046 -0
- package/plugins/kxm/src/vnext-bindings.ts +403 -0
- package/plugins/kxm/src/vnext-config.ts +1646 -0
- package/plugins/kxm/src/vnext-engine-artifacts.ts +86 -0
- package/plugins/kxm/src/vnext-engine-command.ts +533 -0
- package/plugins/kxm/src/vnext-engine-compile.ts +722 -0
- package/plugins/kxm/src/vnext-engine-evidence.ts +273 -0
- package/plugins/kxm/src/vnext-engine-fold.ts +1400 -0
- package/plugins/kxm/src/vnext-engine-gate-records.ts +583 -0
- package/plugins/kxm/src/vnext-engine-plan.ts +717 -0
- package/plugins/kxm/src/vnext-engine.ts +2458 -0
- package/plugins/kxm/src/vnext-gate-hash.ts +10 -0
- package/plugins/kxm/src/vnext-harness.ts +1142 -0
- package/plugins/kxm/src/vnext-init.ts +430 -0
- package/plugins/kxm/src/vnext-migrate.ts +1848 -0
- package/plugins/kxm/src/vnext-oneshot-producer.ts +424 -0
- package/plugins/kxm/src/vnext-permission.ts +936 -0
- package/plugins/kxm/src/vnext-pi-producer.ts +628 -0
- package/plugins/kxm/src/vnext-repair.ts +1094 -0
- package/plugins/kxm/src/vnext-runtime-owner.ts +320 -0
- package/plugins/kxm/src/vnext-runtime-store.ts +1560 -0
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +586 -0
- package/plugins/kxm/src/vnext-runtime.ts +663 -0
- package/plugins/kxm/src/vnext-template.ts +247 -0
- package/plugins/kxm/src/wiki.ts +313 -0
- package/plugins/kxm/src/workflow.ts +1548 -0
- package/schemas/vnext/README.md +46 -0
- package/schemas/vnext/agent.schema.json +40 -0
- package/schemas/vnext/assignment-result.schema.json +66 -0
- package/schemas/vnext/backup-manifest.schema.json +89 -0
- package/schemas/vnext/candidate.schema.json +109 -0
- package/schemas/vnext/common.schema.json +422 -0
- package/schemas/vnext/context-candidate.schema.json +76 -0
- package/schemas/vnext/context-packet.schema.json +192 -0
- package/schemas/vnext/delivery-manifest.schema.json +159 -0
- package/schemas/vnext/environment.schema.json +66 -0
- package/schemas/vnext/gate-registry.schema.json +109 -0
- package/schemas/vnext/handoff-manifest.schema.json +146 -0
- package/schemas/vnext/init-operation.schema.json +61 -0
- package/schemas/vnext/local-repository-bindings.schema.json +30 -0
- package/schemas/vnext/memory-record.schema.json +45 -0
- package/schemas/vnext/migration-decision.schema.json +26 -0
- package/schemas/vnext/migration-plan.schema.json +123 -0
- package/schemas/vnext/migration-receipt.schema.json +52 -0
- package/schemas/vnext/model.schema.json +42 -0
- package/schemas/vnext/permission-diff.schema.json +57 -0
- package/schemas/vnext/prices.schema.json +115 -0
- package/schemas/vnext/project.schema.json +85 -0
- package/schemas/vnext/repository.schema.json +24 -0
- package/schemas/vnext/run-event.schema.json +460 -0
- package/schemas/vnext/session-brief.schema.json +153 -0
- package/schemas/vnext/sync-event.schema.json +234 -0
- package/schemas/vnext/template-provenance.schema.json +38 -0
- package/schemas/vnext/workflow.schema.json +248 -0
- package/scripts/assignment-run.d.mts +354 -0
- package/scripts/assignment-run.mjs +4451 -0
- package/scripts/build-runtime.mjs +56 -0
- package/scripts/check-generated.mjs +77 -0
- package/scripts/check-versions.mjs +34 -0
- package/scripts/emit-codex-artifacts.d.mts +9 -0
- package/scripts/emit-codex-artifacts.mjs +91 -0
- package/scripts/harness-run.d.mts +83 -0
- package/scripts/harness-run.mjs +2095 -0
- package/scripts/kxm-hub.mjs +105 -0
- package/scripts/kxm-publish-npm.mjs +327 -0
- package/scripts/kxm-release-github.mjs +472 -0
- package/scripts/kxm-runtime-supervisor.mjs +7 -0
- package/scripts/kxm-worker.mjs +1127 -0
- package/scripts/kxm.mjs +27 -0
- package/scripts/roster-policy.d.mts +20 -0
- package/scripts/roster-policy.mjs +161 -0
- 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
|
+
}
|