@kontextmind/kxm 0.7.47 → 0.7.50
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 +1 -1
- package/CHANGELOG.md +12 -0
- package/docs/assignment-runner.md +10 -0
- package/docs/kb/qa-authentik-authentication.md +52 -19
- package/docs/kb/qa-hub-on-a-public-host.md +1 -1
- package/docs/operations.md +9 -1
- package/docs/operator-pi-packages.md +3 -2
- package/docs/test-matrix.md +1 -1
- package/docs/workflow-guide.md +3 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +7 -4
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +11 -4
- package/plugins/kxm/dist/runtime.js +11 -4
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/intake.ts +188 -110
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/memory.ts +2 -2
- package/plugins/kxm/src/runtime-service.ts +3 -0
- package/plugins/kxm/src/runtime-store.ts +22 -4
- package/schemas/intake-message.schema.json +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -41,6 +41,18 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
41
41
|
- **`kxm hub start` no longer generates and persists an admin token when it is
|
|
42
42
|
about to refuse** because another hub already owns the claim. A refused start
|
|
43
43
|
used to leave behind credentials the running hub never issued.
|
|
44
|
+
- **Runtime intake contract fixes (released in 0.7.46, found in review):**
|
|
45
|
+
a lost intake insert race accepted different content under an already-used
|
|
46
|
+
idempotency key; resume drained only one page, so held intent beyond 500
|
|
47
|
+
messages was stranded; pause, ingress and admission were not atomic, which could
|
|
48
|
+
strand a message as `held_paused` in an unpaused project; a coordinator rebind
|
|
49
|
+
could widen a tool ceiling by lifting a denial, changing the preset, or dropping
|
|
50
|
+
the tool policy; relabelling a stored message's classification on a duplicate is
|
|
51
|
+
now refused; coordinators now record the real loaded configuration revision
|
|
52
|
+
instead of a hash of project path and runtime id; dispatch order is Runtime
|
|
53
|
+
arrival order, so a backdated timestamp cannot jump the queue; persisted records
|
|
54
|
+
are cross-checked against every duplicated column on read; and the intake schema
|
|
55
|
+
no longer admits contradictory states.
|
|
44
56
|
|
|
45
57
|
## 0.7.0 - 2026-09-11
|
|
46
58
|
|
|
@@ -57,6 +57,16 @@ and models are admitted with strict permission and vendor boundaries:
|
|
|
57
57
|
|
|
58
58
|
## 2. The developer loop
|
|
59
59
|
|
|
60
|
+
> **Environment.** These recipes do **not** auto-load a `.env` from the working
|
|
61
|
+
> directory, and neither the source text nor `just --dump` is allowed to enable it. An
|
|
62
|
+
> untracked, gitignored file must not be able to set `NODE_OPTIONS`
|
|
63
|
+
> and execute code before the runner validates anything — `just --dotenv-path /abs/.env assign …` is the explicit
|
|
64
|
+
> opt-in (`--dotenv-path` both selects and locates the file in `just` 1.58). Arguments are passed
|
|
65
|
+
> positionally and quoted, so a path is never re-read as shell source; the runner's
|
|
66
|
+
> absolute-path requirement is a separate validation rule, not what makes quoting
|
|
67
|
+
> safe. `accept` prints JSON by default but takes no `--json` flag, and its optional
|
|
68
|
+
> `--observed-pr` / `--observed-ci` need the direct script form shown in Step 5.
|
|
69
|
+
|
|
60
70
|
The standard progression follows a slim four-step lifecycle:
|
|
61
71
|
|
|
62
72
|
```text
|
|
@@ -8,18 +8,24 @@ status: "draft"
|
|
|
8
8
|
owner: "@operator"
|
|
9
9
|
created: "2026-09-17"
|
|
10
10
|
updated: "2026-09-18"
|
|
11
|
-
authority: "
|
|
12
|
-
confidence: "
|
|
13
|
-
summary: "
|
|
11
|
+
authority: "hypothesis"
|
|
12
|
+
confidence: "uncertain"
|
|
13
|
+
summary: "Authentik authenticates browsers at the reverse proxy and the tenant portal's backend calls the loopback hub with existing machine tokens. KXM does not interpret browser identity headers, and the hub-side JWT verification and token broker once recommended here are rejected, not deferred."
|
|
14
14
|
tags: ["hub", "authentik", "oidc", "auth"]
|
|
15
|
-
related: ["docs/operations.md", "docs/kb/qa-hub-on-a-public-host.md"]
|
|
15
|
+
related: ["docs/operations.md", "docs/kb/qa-hub-on-a-public-host.md", "plans/plan-per-tenant-hosting.md", "plans/implementation-plan.md"]
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
# Q&A: Authentik (OIDC) for user/role/agent authentication
|
|
19
19
|
|
|
20
20
|
> Researched by `claude --model fable` (planner, read-only) · 2026-09-17 · task_c0bb05339e15 · root review: pending
|
|
21
21
|
|
|
22
|
-
**Short answer
|
|
22
|
+
**Short answer, as of 2026-09-20: no hub-side identity subsystem at all.** Authentik
|
|
23
|
+
authenticates browsers at the tenant's reverse proxy and the portal's own backend calls the
|
|
24
|
+
loopback hub with the machine tokens that already work. The token-broker and JWT
|
|
25
|
+
recommendations that used to sit in this answer are **rejected**, not deferred; they were
|
|
26
|
+
plausible for a shared multi-tenant hub, which is not what we are deploying. Option 1 below —
|
|
27
|
+
forward-auth at the existing proxy, hub unchanged — is the **selected** shape; options 2 and 3
|
|
28
|
+
are rejections, not stages.
|
|
23
29
|
|
|
24
30
|
## What exists today
|
|
25
31
|
|
|
@@ -35,30 +41,57 @@ The hub knows about three credentials. None of them carries a user identity, a r
|
|
|
35
41
|
|
|
36
42
|
## Integration options
|
|
37
43
|
|
|
38
|
-
### 1. Reverse-proxy forward-auth (Authentik outpost at the proxy; hub unchanged)
|
|
44
|
+
### 1. Reverse-proxy forward-auth (Authentik outpost at the proxy; hub unchanged) — **selected shape at the edge**
|
|
39
45
|
|
|
40
|
-
Authentik's proxy outpost authenticates browser sessions and passes headers upstream. The hub ignores those headers today,
|
|
46
|
+
Authentik's proxy outpost authenticates browser sessions and passes headers upstream. The hub ignores those headers today, and under the per-tenant decision it keeps ignoring
|
|
47
|
+
them: the **portal's own backend** holds the hub bearer server-side and calls loopback, while
|
|
48
|
+
the proxy's job is to keep the hub's routes off the public interface entirely. Do not
|
|
49
|
+
configure the proxy to inject the admin token on behalf of every user — that flattens all
|
|
50
|
+
Authentik users to hub admin. What this option does **not** give the hub is per-user identity
|
|
51
|
+
for logging, which is the portal's to record. Effort: low, config only. Risk: low if the hub keeps its token check; medium if someone sets the proxy to add the admin token for every authenticated user, which flattens all Authentik users to admin. Non-loopback bind already requires a token (`hub.ts:468`), so the proxy cannot make the hub anonymous.
|
|
41
52
|
|
|
42
|
-
### 2. Hub validates Authentik-issued JWTs (OIDC discovery + JWKS)
|
|
53
|
+
### 2. Hub validates Authentik-issued JWTs (OIDC discovery + JWKS) — **rejected 2026-09-20; a new decision is required to revisit**
|
|
43
54
|
|
|
44
55
|
Add a second accepted credential in `bearerToken`'s callers: if the bearer parses as a JWT, verify `iss`, `aud`, `exp`, and signature against a cached JWKS from `<issuer>/.well-known/openid-configuration`; else fall through to the existing `safeTokenEqual` path. Code changes: a new `oidc.ts` (discovery, JWKS cache, verify via `node:crypto` `createPublicKey` from JWK, or add `jose`), new `MeshHubOptions.oidc` and `KXM_OIDC_ISSUER` / `KXM_OIDC_AUDIENCE` env in `server.ts` and `hub-env.ts`, and changes to `requireAdminAuth` and `requireProjectAuth` to accept a verified claim set. Mapping: `groups` claim to admin (e.g. `kxm-admin`) and to project scope (e.g. `kxm-project:<name>`). Client side: `HubClient.authToken` (`client.ts:539`) already sends any string as bearer, so a client can pass an Authentik access token unchanged. Effort: medium, roughly 300 to 500 lines plus tests. Risk: medium. New network dependency at auth time (JWKS fetch must fail closed, never skip), clock skew, and the hub must reject `alg: none` and HS256. Agent registration would gain a real principal to store on the agent record (`sub`, `preferred_username`), which the schema can absorb since records are opaque JSON (`store.ts:26`).
|
|
45
56
|
|
|
46
|
-
### 3. Token-broker mapping (Authentik users/groups mint per-user or per-agent kxm tokens)
|
|
57
|
+
### 3. Token-broker mapping (Authentik users/groups mint per-user or per-agent kxm tokens) — **rejected 2026-09-20; a new decision is required to revisit**
|
|
47
58
|
|
|
48
59
|
A small broker (could be a new hub route or a sidecar) accepts an Authentik ID token, verifies it as in option 2, then issues the tokens the runtime already consumes: a project token entry for `requireProjectAuth`, and a `kxm.session-token.v1` with a `toolPolicy` derived from the user's group (`mintSessionToken`, `commands.ts:944`). Mapping table: Authentik group to kxm role id (`role.ts:63` ids `writer`, `planner`, `critic-*`, `verifier`), role `tools` block to `ToolPolicy`, and group to project list to `projectTokens`. Today project tokens are a static map read at startup (`hub.ts:385`), so per-user project tokens need either a dynamic token store in `MeshStore` or short-lived tokens the hub can look up. Session tokens are unsigned (`commands.ts:977`), so a broker-issued one only means something if `parseSessionToken` gains signature verification; otherwise any local process can forge the same payload. Effort: medium to high, because it touches token storage, signing, and revocation. Risk: medium. Benefit: agents and humans converge on one identity source without changing every hub route.
|
|
49
60
|
|
|
50
|
-
## Recommendation
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
## Recommendation (replaced 2026-09-20)
|
|
62
|
+
|
|
63
|
+
Options **2 and 3**, and the staged recommendation that used to sit in this section, are
|
|
64
|
+
**superseded** — option 1 above is the selected shape, not a rejected one. They stay visible
|
|
65
|
+
because they were the plausible answer for a month and someone will meet them again: **do not build a hub-side JWT verifier, a
|
|
66
|
+
token broker, per-user project tokens, or signed session/attempt token issuance as hosting
|
|
67
|
+
prerequisites.** They were a reasonable answer to "one hub, many users"; they are the wrong
|
|
68
|
+
answer to the deployment we actually have, which is **one tenant per box**.
|
|
69
|
+
|
|
70
|
+
1. **Now:** Authentik authenticates and authorizes browsers at the tenant's existing reverse
|
|
71
|
+
proxy, on the **portal's** routes. The portal's server-side backend calls the loopback hub
|
|
72
|
+
and supervisor using the machine credentials that already work (`KXM_AUTH_TOKEN`,
|
|
73
|
+
`KXM_PROJECT_TOKENS`, the persisted hub env record). The hub binds loopback and exposes no
|
|
74
|
+
public listener. Nothing in `hub.ts` changes, and no browser identity header reaches it.
|
|
75
|
+
2. **What stays as-is, unmodified:** the admin-token requirement for non-loopback binding, the
|
|
76
|
+
generated-and-persisted token, project tokens read at startup, and the local loopback
|
|
77
|
+
convenience. A browser-path outage denies browsers; it must not stop authorized machine
|
|
78
|
+
clients.
|
|
79
|
+
3. **Rejected, not deferred:** hub-side JWT verification, group-to-`ToolPolicy` mapping,
|
|
80
|
+
token signing, dynamic per-user token stores, and OAuth2 client-credentials or
|
|
81
|
+
device-flow login for agents. These are not waiting for a trigger; they are the wrong
|
|
82
|
+
shape for a per-tenant hub, and reopening any of them needs a **new written decision**
|
|
83
|
+
that says what the portal boundary cannot do — for example genuine per-user attribution
|
|
84
|
+
of hub writes inside KXM itself, which per-box tenancy does not need. Until such a
|
|
85
|
+
decision exists, treat every mention of them in this file as history.
|
|
86
|
+
|
|
87
|
+
The technical observations underneath remain accurate and are the reason the option is *cheap to
|
|
88
|
+
reject*: unsigned session tokens (`commands.ts:977`), static project tokens read at startup
|
|
89
|
+
(`hub.ts:385`), a fixed-string `HubClient.authToken`, and a plain-string compare in
|
|
90
|
+
`studio-layout.ts:350`. **One item is kept as a live hardening note, not a hosting
|
|
91
|
+
prerequisite:** make that Studio compare timing-safe.
|
|
59
92
|
|
|
60
93
|
## Agent auth specifically
|
|
61
94
|
|
|
62
95
|
Running agents today authenticate non-interactively with whatever string lands in `HubClient.authToken`, resolved by `resolveClientHubAuthToken` (`hub-env.ts:203`): `KXM_AUTH_TOKEN` env, else the persisted project token, else the persisted admin token. The MCP server (`mcp-server.ts:84`) and the Pi extension (`extension.ts:709`) both use this. Pi worker children inherit `process.env` unchanged (`pi-producer.ts:504`), so they get the same token as the supervisor. Tool policy for workers comes from `KXM_ATTEMPT_TOKEN`, which is minted only in tests today (`test/core/commands-policy.test.ts:60`); no runtime path in `plugins/kxm/src` calls `mintAttemptToken`, so engine issuance is planned but not wired.
|
|
63
96
|
|
|
64
|
-
|
|
97
|
+
**Superseded paragraph, kept for the record (see the recommendation above):** with Authentik, agents should use the OAuth2 client-credentials grant (one Authentik application per agent class, or per role such as `writer` and `verifier`), obtain an access token at spawn, and pass it as `KXM_AUTH_TOKEN` to the child. This needs option 2 in the hub so the token verifies, and a refresh hook in `HubClient` since `headers()` reads a fixed string (`client.ts:539`) and access tokens expire. Device-code flow is the fallback for Claude Code sessions that start from a human terminal. Whether Authentik's client-credentials tokens carry `groups` claims by default is unknown from this repo; it must be confirmed against the Authentik provider config before mapping roles from claims.
|
|
@@ -10,7 +10,7 @@ created: "2026-09-17"
|
|
|
10
10
|
updated: "2026-09-18"
|
|
11
11
|
authority: "instruction"
|
|
12
12
|
confidence: "reviewed"
|
|
13
|
-
summary: "One hub is one process and one
|
|
13
|
+
summary: "One hub is one process and one state set per tenant box behind a TLS proxy; it is not multi-tenant and must never be exposed directly to the public internet. Hosting tenancy is the machine plus the portal, and the hub binds loopback."
|
|
14
14
|
tags: ["hub", "deployment", "security"]
|
|
15
15
|
related: ["docs/operations.md", "docs/kb/qa-authentik-authentication.md", "docs/kb/qa-what-the-hub-stores.md"]
|
|
16
16
|
---
|
package/docs/operations.md
CHANGED
|
@@ -146,6 +146,14 @@ Recommended alerts:
|
|
|
146
146
|
|
|
147
147
|
## Backup and restore
|
|
148
148
|
|
|
149
|
+
> **Hub-only today, and labelled as such.** The recipe below stops the hub and copies
|
|
150
|
+
> `.kxm/state/kxm.db`. That is not the whole tenant state set: the Runtime keeps its own
|
|
151
|
+
> `registry.db`, per-project event stores under the user state root, prompt sidecars,
|
|
152
|
+
> bindings and configuration. A restore that follows only these steps can bring the hub back
|
|
153
|
+
> while losing Runtime history. Queue step **S1** replaces this section with a stopped-state
|
|
154
|
+
> procedure covering the full set, and **S5** proves it with one deployed restore before real
|
|
155
|
+
> use; until S1 lands, treat this as the hub database only.
|
|
156
|
+
|
|
149
157
|
SQLite runs in WAL mode. The safest simple backup is a coordinated copy while the hub is stopped:
|
|
150
158
|
|
|
151
159
|
1. Stop the hub gracefully.
|
|
@@ -233,5 +241,5 @@ place from v0.4 databases.
|
|
|
233
241
|
- [What is all stored on the hub?](kb/qa-what-the-hub-stores.md)
|
|
234
242
|
- [Storage engine — SQLite vs DuckDB](kb/qa-sqlite-vs-duckdb.md)
|
|
235
243
|
- [Hub on a public host — multiple users and projects?](kb/qa-hub-on-a-public-host.md)
|
|
236
|
-
- [Authentik
|
|
244
|
+
- [Authentik at the edge: why the hub owns no browser identity](kb/qa-authentik-authentication.md)
|
|
237
245
|
- [Extension install → kxm CLI bootstrap + hub auto-connect](kb/qa-extension-install-and-hub-bootstrap.md)
|
|
@@ -43,8 +43,9 @@ commands for RTK independently of `rtk.ts`.
|
|
|
43
43
|
- The antigravity provider is bundled inside `plugins/kxm` (vendored from
|
|
44
44
|
pi-antigravity, MIT); remove any standalone pi-antigravity Pi extension to
|
|
45
45
|
avoid the double-registration warning. `pi-antigravity` and
|
|
46
|
-
`@tian.zuo/pi-antigravity` are different Pi providers.
|
|
47
|
-
the
|
|
46
|
+
`@tian.zuo/pi-antigravity` are different Pi providers. For Google, this bundled
|
|
47
|
+
provider **is** the admitted route (Tracking → Decided, 2026-09-15); `agy` stays a
|
|
48
|
+
catalog/helper entry rather than the admission path.
|
|
48
49
|
- For KXM development loads, prefer the working tree:
|
|
49
50
|
`pi --no-extensions -e ./plugins/kxm/src/extension.ts`. Add every required
|
|
50
51
|
provider extension with another `-e`; otherwise Pi discovery is disabled.
|
package/docs/test-matrix.md
CHANGED
|
@@ -85,7 +85,7 @@ exercised from `node_modules`.
|
|
|
85
85
|
| Quorum parser boundaries and definition identity | `plugins/kxm/src/workflow.ts`, `test/core/workflow-quorum.test.ts`, `test/core/workflow-definition-hash.test.ts` | Rejects impossible peer pools, verifies degradation bounds, and proves secret-free semantic hash stamping plus credential-rotation invariance |
|
|
86
86
|
| Artifact existence and containment gate | `plugins/kxm/src/artifacts-exist.ts`, `test/core/artifacts-exist.test.ts` | Non-empty regular files pass; missing, empty, non-file, lexical escape, and real-path escape cases fail closed (host-permitted symlink coverage) |
|
|
87
87
|
| Harness inventory probe | `plugins/kxm/src/harness.ts`, `test/core/harness.test.ts` | Detect/auth/dispatch for the builtin catalog; win32 `.exe` / inner npm-package `.exe` / `.cmd` candidate order for npm shims (issue #168); `windows_shim` issue only when the shim answered; allowlisted `name.cmd` shell spawn only; rejected metacharacter commands; Linux still `not_detected` when the bare command is missing. |
|
|
88
|
-
| Headless harness helper | `scripts/harness-run.mjs`, `justfile`, `test/core/harness-run.test.ts` | Offline auth success/logout/garbage, role/mode/pair/provider refusals before spawn, missing brief/schema fail closed with zero spawn, invocation-cwd relative `prompt_file`/`output_schema` vs `request.cwd` (absolute argv tokens; Claude/Codex stdin matches the brief), Pi JSONL multi-`message_end` sums, Claude auxiliary usage, native error-on-exit-0, timeout/empty payload, sidecar-only stderr/answer/error, shell:false argv metacharacters, win32 `.cmd` rejection, and win32 npm inner `claude.exe` / Pi `node.exe`+`cli.js` unwrap. Result v2 transport vs closed model claims, dispatch-before-spawn, grok/codex isolation flags, `max_turns` validation, obsolete v1 diagnosis without rewrite or unknown-schema echo, partial usage on fail/interrupt, signaled null `exitCode` plus exact `signal`, exit-before-stdio-close drain vs bounded linger, type-closed usage/cost (no object leak or zero-coercion), malformed optional text as run-stage failure with retained spend, stdin/pid-record write failures not completed, bounded timeout settle without descendant-death claims, spawn/write-failure stage and spend, and capability-fixture parser evidence (Codex `--ignore-user-config` is not invented in top-level help; captured help bytes are not rescrubbed). Recipe quoting is covered from the justfile body without a just binary (POSIX `sh` + positional argv; Windows uses the recipe's `node -e` / argv shape). Real just integration is optional and skipped when the binary is absent. No live paid smoke. |
|
|
88
|
+
| Headless harness helper | `scripts/harness-run.mjs`, `justfile`, `test/core/harness-run.test.ts` | Offline auth success/logout/garbage, role/mode/pair/provider refusals before spawn, missing brief/schema fail closed with zero spawn, invocation-cwd relative `prompt_file`/`output_schema` vs `request.cwd` (absolute argv tokens; Claude/Codex stdin matches the brief), Pi JSONL multi-`message_end` sums, Claude auxiliary usage, native error-on-exit-0, timeout/empty payload, sidecar-only stderr/answer/error, shell:false argv metacharacters, win32 `.cmd` rejection, and win32 npm inner `claude.exe` / Pi `node.exe`+`cli.js` unwrap. Result v2 transport vs closed model claims, dispatch-before-spawn, grok/codex isolation flags, `max_turns` validation, obsolete v1 diagnosis without rewrite or unknown-schema echo, partial usage on fail/interrupt, signaled null `exitCode` plus exact `signal`, exit-before-stdio-close drain vs bounded linger, type-closed usage/cost (no object leak or zero-coercion), malformed optional text as run-stage failure with retained spend, stdin/pid-record write failures not completed, bounded timeout settle without descendant-death claims, spawn/write-failure stage and spend, and capability-fixture parser evidence (Codex `--ignore-user-config` is not invented in top-level help; captured help bytes are not rescrubbed). Recipe quoting is covered from the justfile body without a just binary (POSIX `sh` + positional argv; Windows uses the recipe's `node -e` / argv shape). The developer entry points are gated three ways: docs-to-recipe parity (a documented `just` verb in command form, across inline code or a fenced line with an optional `#`, tolerant of stray whitespace, pipe-separated alternations, leading interpreter options and `~~~` fences, reading a shell pipe as one command rather than an alternation, skipping a glob family like `review-*` instead of demanding a recipe, and *not* parsing prose, headings or captured listing output); per-recipe boundaries (**the load-bearing gate parses nothing**: every line that mentions `assignment-run.mjs` must be one of the seven pinned proof bodies and there must be exactly seven, so a header form no parser recognises still cannot hide a call site; column-0 comments are documentation and excluded), plus a normalized-text pass (`\`-continuations folded, comments stripped) and a second pass over `just --dump`, the interpreter's own rendering, skipped with a visible reason without the binary; the recipe name set must equal a pinned list, duplicate headers and duplicate `run :=` bindings are refused, `set allow-duplicate*`, `import`/`mod` in any spelling and `alias` are refused, `run :=` and `dispatch` are asserted exactly, and non-proof bodies are token-checked against raw text because comment-stripping first hid an executable suffix that `--dump` reproduced verbatim. Stated limit: drift protection, not a sandbox — a body can assemble its command at runtime, and anyone who can edit the file can already do what it does. Auto-loading a working-directory dotenv file is refused by a brake on any `set dotenv*` spelling, plus a real-`just` probe whose preload module writes a marker file itself — so the assertion is that injected code executed — paired with controls on the same entry point: explicit `--dotenv-path`, a justfile with the setting re-added, and a real `witness` recipe run both ways. Real just integration is optional and skipped when the binary is absent. No live paid smoke. |
|
|
89
89
|
| Long-lived headless coordinator | `scripts/kxm-worker.mjs` | Restart limits, spawn failure, collision-resistant ownership, exact resource and tool loading, raw-output isolation, bounded RPC framing, bounded drain, hung-tool recovery, provider/model fallback, and `--continue` fallback are automated; the opt-in real-Pi gate verifies two workers, discovery, request/reply, fanout, durable restart/resume, journal, and checkpoint |
|
|
90
90
|
| GitHub check signal adapter | `plugins/kxm/src/github-watch.ts` | Deterministic pagination, conclusion, retry, and per-wait delivery-generation states in `test/core/github-watch.test.ts` |
|
|
91
91
|
| Operator CLI | `scripts/kxm.mjs` | Isolated workspace commands in `test/core/cli.test.ts`; the packed artifact is installed locally and with the documented global `--omit=peer` path by `test/core/package-install.test.ts` |
|
package/docs/workflow-guide.md
CHANGED
|
@@ -30,7 +30,9 @@ Existing claims inside preserved candidate lines are research claims, not dispat
|
|
|
30
30
|
|
|
31
31
|
## Selection Policy
|
|
32
32
|
|
|
33
|
-
Candidate selection is measured per role. Filter stages are optional and are not mandatory Tier-0 gating. Prefer a provider-native authenticated subscription when Tracking says that harness is eligible. For Gemini candidates (`google/*`), the admitted
|
|
33
|
+
Candidate selection is measured per role. Filter stages are optional and are not mandatory Tier-0 gating. Prefer a provider-native authenticated subscription when Tracking says that harness is eligible. For Gemini candidates (`google/*`), the admitted route is the bundled `antigravity` **Pi
|
|
34
|
+
provider** (Tracking → Decided, 2026-09-15) — not the OpenRouter provider id, and not a
|
|
35
|
+
shell-out to `agy`, which stays a catalog/helper entry. Do not change the candidate ids themselves (they are dated research). Evidence and review remain workflow-specific. Both the Fable architecture critic and the Sol CLI critic remain required for this developer assignment runner.
|
|
34
36
|
|
|
35
37
|
### Cost band reference (dated candidates)
|
|
36
38
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "kxm",
|
|
4
4
|
"displayName": "KXM",
|
|
5
|
-
"version": "0.7.
|
|
5
|
+
"version": "0.7.50",
|
|
6
6
|
"description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "KontextMind",
|
package/plugins/kxm/dist/cli.js
CHANGED
|
@@ -34732,8 +34732,9 @@ Follow [\`AGENTS.md\`](AGENTS.md). Official phase tracking:
|
|
|
34732
34732
|
[\`plans/implementation-plan.md\`](plans/implementation-plan.md#tracking-working-tree-not-a-release).
|
|
34733
34733
|
|
|
34734
34734
|
You are the **planner / architecture critic** unless the human explicitly asks
|
|
34735
|
-
you to implement. Default writer is native Grok CLI (\`grok --model grok-4.6\`)
|
|
34736
|
-
If \`grok\` is logged out,
|
|
34735
|
+
you to implement. Default writer is native Grok CLI (\`grok --model grok-4.6\`) \u2014 a starting
|
|
34736
|
+
rotation, not a sole writer. If \`grok\` is logged out, never bill Grok through another harness;
|
|
34737
|
+
use a relief route Tracking **admits**, or stop and name the limits hit. Your reviews are
|
|
34737
34738
|
artifacts, not hub \`peer-reply\` evidence.
|
|
34738
34739
|
`;
|
|
34739
34740
|
const claudeExisted = existsSync25(claudePath);
|
|
@@ -34747,8 +34748,10 @@ artifacts, not hub \`peer-reply\` evidence.
|
|
|
34747
34748
|
Follow [\`AGENTS.md\`](AGENTS.md). Official phase tracking:
|
|
34748
34749
|
[\`plans/implementation-plan.md\`](plans/implementation-plan.md#tracking-working-tree-not-a-release).
|
|
34749
34750
|
|
|
34750
|
-
Google
|
|
34751
|
-
|
|
34751
|
+
Google goes through the \`antigravity\` **Pi provider** (Tracking \u2192 Decided,
|
|
34752
|
+
2026-09-15); \`agy\` stays a harness catalog/helper entry, not the admission path.
|
|
34753
|
+
Current admissions come from Tracking and \`kxm harness list\`. Starting rotation
|
|
34754
|
+
remains Grok.
|
|
34752
34755
|
`;
|
|
34753
34756
|
const geminiExisted = existsSync25(geminiPath);
|
|
34754
34757
|
if (updateHarnessDocument(geminiPath, block, geminiHeader)) {
|
|
@@ -17121,7 +17121,7 @@ async function deliverInboxNotification(messageId, delivered, notify) {
|
|
|
17121
17121
|
}
|
|
17122
17122
|
|
|
17123
17123
|
// plugins/kxm/src/mcp-server.ts
|
|
17124
|
-
var VERSION = "0.7.
|
|
17124
|
+
var VERSION = "0.7.50";
|
|
17125
17125
|
var inbox = /* @__PURE__ */ new Map();
|
|
17126
17126
|
var notifiedInbox = /* @__PURE__ */ new Set();
|
|
17127
17127
|
var meshClient;
|
|
@@ -18558,14 +18558,14 @@ var KxmRunEventStore = class {
|
|
|
18558
18558
|
`).get(projectId, coordinatorId, idempotencyKey);
|
|
18559
18559
|
return row ? intakeFromRow(row) : void 0;
|
|
18560
18560
|
}
|
|
18561
|
-
/** Intake rows in the given dispatch states,
|
|
18561
|
+
/** Intake rows in the given dispatch states, in arrival order (replay-safe). */
|
|
18562
18562
|
intakeInStates(projectId, states, limit = 100) {
|
|
18563
18563
|
if (states.length === 0) return [];
|
|
18564
18564
|
const placeholders = states.map(() => "?").join(", ");
|
|
18565
18565
|
const rows = this.database.prepare(`
|
|
18566
18566
|
SELECT * FROM intake_messages
|
|
18567
18567
|
WHERE project_id = ? AND dispatch_state IN (${placeholders})
|
|
18568
|
-
ORDER BY
|
|
18568
|
+
ORDER BY rowid ASC
|
|
18569
18569
|
LIMIT ?
|
|
18570
18570
|
`).all(projectId, ...states, limit);
|
|
18571
18571
|
return rows.map(intakeFromRow);
|
|
@@ -18839,8 +18839,11 @@ var KxmRunEventStore = class {
|
|
|
18839
18839
|
};
|
|
18840
18840
|
function coordinatorFromRow(row) {
|
|
18841
18841
|
const parsed = JSON.parse(row.record);
|
|
18842
|
+
if (row.schema !== "kxm.coordinator.v1") {
|
|
18843
|
+
throw runtimeError("coordinator_record_divergent", row.coordinator_id, `unexpected coordinator schema ${row.schema}`);
|
|
18844
|
+
}
|
|
18842
18845
|
validateCoordinator(parsed, row.coordinator_id);
|
|
18843
|
-
if (parsed.coordinatorId !== row.coordinator_id || parsed.ceilingHash !== row.ceiling_hash || parsed.configRevision !== row.config_revision || parsed.boundAt !== row.bound_at) {
|
|
18846
|
+
if (parsed.coordinatorId !== row.coordinator_id || parsed.projectId !== row.project_id || parsed.role !== row.role || parsed.channel !== row.channel || parsed.ceilingHash !== row.ceiling_hash || parsed.configRevision !== row.config_revision || parsed.boundAt !== row.bound_at) {
|
|
18844
18847
|
throw runtimeError("coordinator_record_divergent", row.coordinator_id, "coordinator columns do not match the persisted record");
|
|
18845
18848
|
}
|
|
18846
18849
|
return {
|
|
@@ -18856,8 +18859,11 @@ function coordinatorFromRow(row) {
|
|
|
18856
18859
|
}
|
|
18857
18860
|
function intakeFromRow(row) {
|
|
18858
18861
|
const parsed = JSON.parse(row.record);
|
|
18862
|
+
if (row.schema !== "kxm.intake-message.v1") {
|
|
18863
|
+
throw runtimeError("intake_record_divergent", row.message_id, `unexpected intake schema ${row.schema}`);
|
|
18864
|
+
}
|
|
18859
18865
|
validateIntakeMessage(parsed, row.message_id);
|
|
18860
|
-
if (parsed.messageId !== row.message_id || parsed.contentHash !== row.content_hash || parsed.receivedAt !== row.received_at || parsed.dispatch?.state !== row.dispatch_state) {
|
|
18866
|
+
if (parsed.messageId !== row.message_id || parsed.projectId !== row.project_id || parsed.coordinatorId !== row.coordinator_id || parsed.idempotencyKey !== row.idempotency_key || parsed.contentHash !== row.content_hash || parsed.receivedAt !== row.received_at || parsed.dispatch?.state !== row.dispatch_state) {
|
|
18861
18867
|
throw runtimeError("intake_record_divergent", row.message_id, "intake columns do not match the persisted record");
|
|
18862
18868
|
}
|
|
18863
18869
|
return {
|
|
@@ -22191,6 +22197,7 @@ function openKxmRuntimeContext(projectRoot, options) {
|
|
|
22191
22197
|
projectId: registration.projectId,
|
|
22192
22198
|
homeRuntimeId: registration.homeRuntimeId,
|
|
22193
22199
|
eventStore,
|
|
22200
|
+
configRevision: bundle.configRevision,
|
|
22194
22201
|
...options.budgetClock ? { budgetClock: options.budgetClock } : {}
|
|
22195
22202
|
};
|
|
22196
22203
|
} catch (error) {
|
|
@@ -18998,14 +18998,14 @@ var KxmRunEventStore = class {
|
|
|
18998
18998
|
`).get(projectId, coordinatorId, idempotencyKey);
|
|
18999
18999
|
return row ? intakeFromRow(row) : void 0;
|
|
19000
19000
|
}
|
|
19001
|
-
/** Intake rows in the given dispatch states,
|
|
19001
|
+
/** Intake rows in the given dispatch states, in arrival order (replay-safe). */
|
|
19002
19002
|
intakeInStates(projectId, states, limit = 100) {
|
|
19003
19003
|
if (states.length === 0) return [];
|
|
19004
19004
|
const placeholders = states.map(() => "?").join(", ");
|
|
19005
19005
|
const rows = this.database.prepare(`
|
|
19006
19006
|
SELECT * FROM intake_messages
|
|
19007
19007
|
WHERE project_id = ? AND dispatch_state IN (${placeholders})
|
|
19008
|
-
ORDER BY
|
|
19008
|
+
ORDER BY rowid ASC
|
|
19009
19009
|
LIMIT ?
|
|
19010
19010
|
`).all(projectId, ...states, limit);
|
|
19011
19011
|
return rows.map(intakeFromRow);
|
|
@@ -19279,8 +19279,11 @@ var KxmRunEventStore = class {
|
|
|
19279
19279
|
};
|
|
19280
19280
|
function coordinatorFromRow(row) {
|
|
19281
19281
|
const parsed = JSON.parse(row.record);
|
|
19282
|
+
if (row.schema !== "kxm.coordinator.v1") {
|
|
19283
|
+
throw runtimeError("coordinator_record_divergent", row.coordinator_id, `unexpected coordinator schema ${row.schema}`);
|
|
19284
|
+
}
|
|
19282
19285
|
validateCoordinator(parsed, row.coordinator_id);
|
|
19283
|
-
if (parsed.coordinatorId !== row.coordinator_id || parsed.ceilingHash !== row.ceiling_hash || parsed.configRevision !== row.config_revision || parsed.boundAt !== row.bound_at) {
|
|
19286
|
+
if (parsed.coordinatorId !== row.coordinator_id || parsed.projectId !== row.project_id || parsed.role !== row.role || parsed.channel !== row.channel || parsed.ceilingHash !== row.ceiling_hash || parsed.configRevision !== row.config_revision || parsed.boundAt !== row.bound_at) {
|
|
19284
19287
|
throw runtimeError("coordinator_record_divergent", row.coordinator_id, "coordinator columns do not match the persisted record");
|
|
19285
19288
|
}
|
|
19286
19289
|
return {
|
|
@@ -19296,8 +19299,11 @@ function coordinatorFromRow(row) {
|
|
|
19296
19299
|
}
|
|
19297
19300
|
function intakeFromRow(row) {
|
|
19298
19301
|
const parsed = JSON.parse(row.record);
|
|
19302
|
+
if (row.schema !== "kxm.intake-message.v1") {
|
|
19303
|
+
throw runtimeError("intake_record_divergent", row.message_id, `unexpected intake schema ${row.schema}`);
|
|
19304
|
+
}
|
|
19299
19305
|
validateIntakeMessage(parsed, row.message_id);
|
|
19300
|
-
if (parsed.messageId !== row.message_id || parsed.contentHash !== row.content_hash || parsed.receivedAt !== row.received_at || parsed.dispatch?.state !== row.dispatch_state) {
|
|
19306
|
+
if (parsed.messageId !== row.message_id || parsed.projectId !== row.project_id || parsed.coordinatorId !== row.coordinator_id || parsed.idempotencyKey !== row.idempotency_key || parsed.contentHash !== row.content_hash || parsed.receivedAt !== row.received_at || parsed.dispatch?.state !== row.dispatch_state) {
|
|
19301
19307
|
throw runtimeError("intake_record_divergent", row.message_id, "intake columns do not match the persisted record");
|
|
19302
19308
|
}
|
|
19303
19309
|
return {
|
|
@@ -22634,6 +22640,7 @@ function openKxmRuntimeContext(projectRoot, options) {
|
|
|
22634
22640
|
projectId: registration.projectId,
|
|
22635
22641
|
homeRuntimeId: registration.homeRuntimeId,
|
|
22636
22642
|
eventStore,
|
|
22643
|
+
configRevision: bundle.configRevision,
|
|
22637
22644
|
...options.budgetClock ? { budgetClock: options.budgetClock } : {}
|
|
22638
22645
|
};
|
|
22639
22646
|
} catch (error) {
|
package/plugins/kxm/package.json
CHANGED
|
@@ -169,6 +169,13 @@ export function bindKxmCoordinator(
|
|
|
169
169
|
record: kxmCanonicalJson(record as unknown as JsonValue),
|
|
170
170
|
});
|
|
171
171
|
if (!replaced) {
|
|
172
|
+
// Another process got the same policy through. Return its identity when it
|
|
173
|
+
// reached the ceiling we asked for; anything else is a real conflict.
|
|
174
|
+
const winner = context.eventStore.coordinatorInSlot(context.projectId, role, channel);
|
|
175
|
+
const record2 = winner ? (JSON.parse(winner.record) as KxmCoordinatorRecord) : undefined;
|
|
176
|
+
if (record2 && record2.ceilingHash === ceilingHash) {
|
|
177
|
+
return { coordinator: record2, created: false };
|
|
178
|
+
}
|
|
172
179
|
throw runtimeError("coordinator_write_lost", existing.coordinatorId, "the coordinator slot changed underneath this rebind");
|
|
173
180
|
}
|
|
174
181
|
return { coordinator: record, created: true };
|
|
@@ -186,11 +193,10 @@ export function bindKxmCoordinator(
|
|
|
186
193
|
authority,
|
|
187
194
|
boundAt: now,
|
|
188
195
|
boundBy: actor,
|
|
189
|
-
configRevision:
|
|
196
|
+
configRevision: context.configRevision,
|
|
190
197
|
ceilingHash,
|
|
191
198
|
};
|
|
192
|
-
persistCoordinator(context, record);
|
|
193
|
-
return { coordinator: record, created: true };
|
|
199
|
+
return { ...persistCoordinator(context, record), created: true };
|
|
194
200
|
}
|
|
195
201
|
|
|
196
202
|
/** Resolve a coordinator by id; unknown identity fails closed. */
|
|
@@ -239,65 +245,92 @@ export function acceptKxmIntakeMessage(
|
|
|
239
245
|
const bytes = Buffer.byteLength(input.content, "utf8");
|
|
240
246
|
const contentHash = `sha256:${createHash("sha256").update(input.content, "utf8").digest("hex")}`;
|
|
241
247
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
248
|
+
// One transaction covers the slot probe, the pause read and the write, so a
|
|
249
|
+
// concurrent resume cannot strand this row in an unpaused project and a racing
|
|
250
|
+
// writer with different content cannot be mistaken for a duplicate.
|
|
251
|
+
return context.eventStore.transaction(() => {
|
|
252
|
+
const existingRow = context.eventStore.intakeBySlot(context.projectId, coordinator.coordinatorId, key);
|
|
253
|
+
if (existingRow) {
|
|
254
|
+
return { message: requireSamePayload(existingRow, contentHash, classification, key), duplicate: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (bytes > MAX_INTAKE_CONTENT_BYTES) {
|
|
246
258
|
throw runtimeError(
|
|
247
|
-
"
|
|
248
|
-
|
|
249
|
-
`
|
|
259
|
+
"intake_content_too_large",
|
|
260
|
+
coordinator.coordinatorId,
|
|
261
|
+
`intake content is ${bytes} bytes; the limit is ${MAX_INTAKE_CONTENT_BYTES}`,
|
|
250
262
|
);
|
|
251
263
|
}
|
|
252
|
-
return { message: existing, duplicate: true };
|
|
253
|
-
}
|
|
254
264
|
|
|
255
|
-
|
|
265
|
+
// Only secrets are withheld, and that guarantee is about *this layer's* storage
|
|
266
|
+
// decision: classification is supplied by the caller, so an untrusted adapter
|
|
267
|
+
// can still label a credential "project" and have it persisted here. Widening
|
|
268
|
+
// this to detection is a separate, reviewed slice.
|
|
269
|
+
const persistContent = classification !== "secret";
|
|
270
|
+
const paused = isKxmProjectPaused(context);
|
|
271
|
+
const message: KxmIntakeMessage = {
|
|
272
|
+
schema: "kxm.intake-message.v1",
|
|
273
|
+
messageId: newId("msg"),
|
|
274
|
+
projectId: context.projectId,
|
|
275
|
+
coordinatorId: coordinator.coordinatorId,
|
|
276
|
+
receivedAt: now,
|
|
277
|
+
source: { kind: sourceKind, id: sourceId },
|
|
278
|
+
idempotencyKey: key,
|
|
279
|
+
contentHash,
|
|
280
|
+
...(persistContent ? { content: input.content } : { contentOmittedReason: "secret-classified" as const }),
|
|
281
|
+
classification,
|
|
282
|
+
dispatch: paused
|
|
283
|
+
? { state: "held_paused", reason: "project_paused", updatedAt: now }
|
|
284
|
+
: { state: "ready" },
|
|
285
|
+
};
|
|
286
|
+
validateIntakeMessage(message, message.messageId);
|
|
287
|
+
|
|
288
|
+
const inserted = context.eventStore.insertIntakeMessageIfAbsent({
|
|
289
|
+
messageId: message.messageId,
|
|
290
|
+
projectId: message.projectId,
|
|
291
|
+
coordinatorId: message.coordinatorId,
|
|
292
|
+
idempotencyKey: message.idempotencyKey,
|
|
293
|
+
contentHash: message.contentHash,
|
|
294
|
+
receivedAt: message.receivedAt,
|
|
295
|
+
dispatchState: message.dispatch.state,
|
|
296
|
+
record: kxmCanonicalJson(message as unknown as JsonValue),
|
|
297
|
+
});
|
|
298
|
+
if (!inserted) {
|
|
299
|
+
// Lost the race for the slot. The winner is only a duplicate if it carried
|
|
300
|
+
// the same content; anything else is the same conflict we refuse serially.
|
|
301
|
+
const winner = context.eventStore.intakeBySlot(context.projectId, coordinator.coordinatorId, key);
|
|
302
|
+
if (!winner) throw runtimeError("intake_write_lost", coordinator.coordinatorId, "intake write lost its slot and left no record");
|
|
303
|
+
return { message: requireSamePayload(winner, contentHash, classification, key), duplicate: true };
|
|
304
|
+
}
|
|
305
|
+
return { message, duplicate: false };
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function requireSamePayload(
|
|
310
|
+
row: { record: string },
|
|
311
|
+
contentHash: string,
|
|
312
|
+
classification: KxmIntakeClassification,
|
|
313
|
+
key: string,
|
|
314
|
+
): KxmIntakeMessage {
|
|
315
|
+
const message = JSON.parse(row.record) as KxmIntakeMessage;
|
|
316
|
+
if (message.contentHash !== contentHash) {
|
|
256
317
|
throw runtimeError(
|
|
257
|
-
"
|
|
258
|
-
|
|
259
|
-
`
|
|
318
|
+
"intake_payload_conflict",
|
|
319
|
+
message.messageId,
|
|
320
|
+
`idempotency key ${key} was already used with different content`,
|
|
260
321
|
);
|
|
261
322
|
}
|
|
262
|
-
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
coordinatorId: coordinator.coordinatorId,
|
|
272
|
-
receivedAt: now,
|
|
273
|
-
source: { kind: sourceKind, id: sourceId },
|
|
274
|
-
idempotencyKey: key,
|
|
275
|
-
contentHash,
|
|
276
|
-
...(persistContent ? { content: input.content } : { contentOmittedReason: "secret-classified" as const }),
|
|
277
|
-
classification,
|
|
278
|
-
dispatch: paused
|
|
279
|
-
? { state: "held_paused", reason: "project_paused", updatedAt: now }
|
|
280
|
-
: { state: "ready" },
|
|
281
|
-
};
|
|
282
|
-
validateIntakeMessage(message, message.messageId);
|
|
283
|
-
|
|
284
|
-
const inserted = context.eventStore.insertIntakeMessageIfAbsent({
|
|
285
|
-
messageId: message.messageId,
|
|
286
|
-
projectId: message.projectId,
|
|
287
|
-
coordinatorId: message.coordinatorId,
|
|
288
|
-
idempotencyKey: message.idempotencyKey,
|
|
289
|
-
contentHash: message.contentHash,
|
|
290
|
-
receivedAt: message.receivedAt,
|
|
291
|
-
dispatchState: message.dispatch.state,
|
|
292
|
-
record: kxmCanonicalJson(message as unknown as JsonValue),
|
|
293
|
-
});
|
|
294
|
-
if (!inserted) {
|
|
295
|
-
// Lost the race against an identical key: report the winner as the duplicate.
|
|
296
|
-
const winner = context.eventStore.intakeBySlot(context.projectId, coordinator.coordinatorId, key);
|
|
297
|
-
if (!winner) throw runtimeError("intake_write_lost", coordinator.coordinatorId, "intake write lost its slot and left no record");
|
|
298
|
-
return { message: JSON.parse(winner.record) as KxmIntakeMessage, duplicate: true };
|
|
323
|
+
// Re-labelling the same payload is not a no-op: a retry marked `secret` must not
|
|
324
|
+
// quietly hand back a stored plaintext record, and a retry marked `project` must
|
|
325
|
+
// not launder a row that was withheld.
|
|
326
|
+
if (message.classification !== classification) {
|
|
327
|
+
throw runtimeError(
|
|
328
|
+
"intake_classification_conflict",
|
|
329
|
+
message.messageId,
|
|
330
|
+
`idempotency key ${key} was already recorded as ${message.classification}, not ${classification}`,
|
|
331
|
+
);
|
|
299
332
|
}
|
|
300
|
-
return
|
|
333
|
+
return message;
|
|
301
334
|
}
|
|
302
335
|
|
|
303
336
|
/** Pending intake that may be dispatched, oldest first. Empty while paused. */
|
|
@@ -322,29 +355,33 @@ export function admitKxmIntakeRun(
|
|
|
322
355
|
const runId = requireText(input.runId, "runId", 144);
|
|
323
356
|
if (!COORDINATOR_ID_RE.test(runId)) throw runtimeError("intake_run_id_invalid", id, "runId is not a well-formed opaque id");
|
|
324
357
|
const now = requireTimestamp(input.now ?? new Date().toISOString(), "now");
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
358
|
+
// The pause read, the state read and the guarded transition commit together, so
|
|
359
|
+
// a pause cannot slip in between "not paused" and "admitted".
|
|
360
|
+
return context.eventStore.transaction(() => {
|
|
361
|
+
if (isKxmProjectPaused(context)) {
|
|
362
|
+
throw runtimeError("intake_paused", id, "the project is paused; no fresh dispatch may be admitted");
|
|
363
|
+
}
|
|
364
|
+
const row = context.eventStore.intakeMessage(id);
|
|
365
|
+
if (!row) throw runtimeError("intake_message_unknown", id, "no such intake message in this project");
|
|
366
|
+
const message = JSON.parse(row.record) as KxmIntakeMessage;
|
|
367
|
+
if (message.dispatch.state === "admitted") {
|
|
368
|
+
if (message.dispatch.runId === runId) return message;
|
|
369
|
+
throw runtimeError("intake_second_admission", id, `message already admitted as ${message.dispatch.runId}`);
|
|
370
|
+
}
|
|
371
|
+
if (message.dispatch.state !== "ready") {
|
|
372
|
+
throw runtimeError("intake_not_dispatchable", id, `intake state ${message.dispatch.state} cannot be admitted`);
|
|
373
|
+
}
|
|
374
|
+
const next: KxmIntakeMessage = { ...message, dispatch: { state: "admitted", runId, updatedAt: now } };
|
|
375
|
+
validateIntakeMessage(next, next.messageId);
|
|
376
|
+
const updated = context.eventStore.updateIntakeDispatch(id, "ready", { state: "admitted", record: kxmCanonicalJson(next as unknown as JsonValue) });
|
|
377
|
+
if (!updated) {
|
|
378
|
+
const racer = context.eventStore.intakeMessage(id);
|
|
379
|
+
const current = racer ? (JSON.parse(racer.record) as KxmIntakeMessage) : undefined;
|
|
380
|
+
if (current?.dispatch.state === "admitted" && current.dispatch.runId === runId) return current;
|
|
381
|
+
throw runtimeError("intake_dispatch_race", id, "the intake record changed underneath this admission");
|
|
382
|
+
}
|
|
383
|
+
return next;
|
|
384
|
+
});
|
|
348
385
|
}
|
|
349
386
|
|
|
350
387
|
/**
|
|
@@ -378,9 +415,13 @@ export function setKxmProjectPause(
|
|
|
378
415
|
actor: record.actor,
|
|
379
416
|
record: kxmCanonicalJson(record as unknown as JsonValue),
|
|
380
417
|
};
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return
|
|
418
|
+
// Control write and the held-intent release are one transaction: a crash leaves
|
|
419
|
+
// either a paused project with held rows, or a resumed project with none.
|
|
420
|
+
return context.eventStore.transaction(() => {
|
|
421
|
+
context.eventStore.putProjectControl(control);
|
|
422
|
+
const released = input.paused ? [] : releaseHeldIntake(context, now);
|
|
423
|
+
return { control, released };
|
|
424
|
+
});
|
|
384
425
|
}
|
|
385
426
|
|
|
386
427
|
/** Whether fresh dispatch is currently blocked for this project. */
|
|
@@ -389,20 +430,27 @@ export function isKxmProjectPaused(context: KxmRuntimeContext): boolean {
|
|
|
389
430
|
}
|
|
390
431
|
|
|
391
432
|
function releaseHeldIntake(context: KxmRuntimeContext, now: string): KxmIntakeMessage[] {
|
|
392
|
-
const held = context.eventStore.intakeInStates(context.projectId, ["held_paused"], 500);
|
|
393
433
|
const released: KxmIntakeMessage[] = [];
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (
|
|
399
|
-
|
|
434
|
+
// Drain every held row. A paging loop, not a single capped page: stranding the
|
|
435
|
+
// 501st message behind a "resume releases held intent" claim is a lie of omission.
|
|
436
|
+
for (let page = 0; page < 1000; page += 1) {
|
|
437
|
+
const held = context.eventStore.intakeInStates(context.projectId, ["held_paused"], 500);
|
|
438
|
+
if (held.length === 0) break;
|
|
439
|
+
for (const row of held) {
|
|
440
|
+
const message = JSON.parse(row.record) as KxmIntakeMessage;
|
|
441
|
+
const next: KxmIntakeMessage = { ...message, dispatch: { state: "ready", updatedAt: now } };
|
|
442
|
+
validateIntakeMessage(next, next.messageId);
|
|
443
|
+
if (context.eventStore.updateIntakeDispatch(row.messageId, "held_paused", { state: "ready", record: kxmCanonicalJson(next as unknown as JsonValue) })) {
|
|
444
|
+
released.push(next);
|
|
445
|
+
}
|
|
400
446
|
}
|
|
447
|
+
const stillHeld = context.eventStore.intakeInStates(context.projectId, ["held_paused"], 1).length;
|
|
448
|
+
if (stillHeld === 0) break;
|
|
401
449
|
}
|
|
402
450
|
return released;
|
|
403
451
|
}
|
|
404
452
|
|
|
405
|
-
function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRecord):
|
|
453
|
+
function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRecord): { coordinator: KxmCoordinatorRecord } {
|
|
406
454
|
validateCoordinator(record, record.coordinatorId);
|
|
407
455
|
const inserted = context.eventStore.insertCoordinatorIfAbsent({
|
|
408
456
|
coordinatorId: record.coordinatorId,
|
|
@@ -414,9 +462,14 @@ function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRe
|
|
|
414
462
|
boundAt: record.boundAt,
|
|
415
463
|
record: kxmCanonicalJson(record as unknown as JsonValue),
|
|
416
464
|
});
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
465
|
+
if (inserted) return { coordinator: record };
|
|
466
|
+
// Lost the race for an empty slot: binding is create-once, so the winner is the
|
|
467
|
+
// answer whenever it reached the same ceiling. Only a different ceiling is a
|
|
468
|
+
// conflict worth reporting.
|
|
469
|
+
const winner = context.eventStore.coordinatorInSlot(record.projectId, record.role, record.channel);
|
|
470
|
+
const won = winner ? (JSON.parse(winner.record) as KxmCoordinatorRecord) : undefined;
|
|
471
|
+
if (won && won.ceilingHash === record.ceilingHash) return { coordinator: won };
|
|
472
|
+
throw runtimeError("coordinator_write_lost", record.coordinatorId, "the coordinator slot was claimed by a different ceiling");
|
|
420
473
|
}
|
|
421
474
|
|
|
422
475
|
function assertCeilingNotWidened(
|
|
@@ -432,10 +485,28 @@ function assertCeilingNotWidened(
|
|
|
432
485
|
if (added.length > 0) {
|
|
433
486
|
throw runtimeError("coordinator_rebind_widens_effects", coordinatorId, `a rebind may not add effects: ${added.join(", ")}`);
|
|
434
487
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
488
|
+
// Tool ceilings are only narrowing-safe if every restriction is checked. An
|
|
489
|
+
// omitted list or a changed preset can select a broader default, so those moves
|
|
490
|
+
// are refused outright until their semantics are defined here.
|
|
491
|
+
if ((previous.tools === undefined) !== (next.tools === undefined)) {
|
|
492
|
+
throw runtimeError("coordinator_rebind_widens_tools", coordinatorId, "a rebind may not add or remove the tool policy");
|
|
493
|
+
}
|
|
494
|
+
if (previous.tools && next.tools) {
|
|
495
|
+
const previousTools = previous.tools;
|
|
496
|
+
const nextTools = next.tools;
|
|
497
|
+
if (previousTools.preset !== nextTools.preset) {
|
|
498
|
+
throw runtimeError("coordinator_rebind_changes_preset", coordinatorId, "a rebind may not change the tool preset; its default is not defined here");
|
|
499
|
+
}
|
|
500
|
+
const allowedBefore = new Set(previousTools.allow ?? []);
|
|
501
|
+
const newlyAllowed = (nextTools.allow ?? []).filter((tool) => !allowedBefore.has(tool));
|
|
502
|
+
if (newlyAllowed.length > 0) {
|
|
503
|
+
throw runtimeError("coordinator_rebind_widens_tools", coordinatorId, `a rebind may not allow new tools: ${newlyAllowed.join(", ")}`);
|
|
504
|
+
}
|
|
505
|
+
const deniedBefore = new Set(previousTools.deny ?? []);
|
|
506
|
+
const undenied = [...deniedBefore].filter((tool) => !(nextTools.deny ?? []).includes(tool));
|
|
507
|
+
if (undenied.length > 0) {
|
|
508
|
+
throw runtimeError("coordinator_rebind_removes_denials", coordinatorId, `a rebind may not lift denials: ${undenied.join(", ")}`);
|
|
509
|
+
}
|
|
439
510
|
}
|
|
440
511
|
}
|
|
441
512
|
|
|
@@ -459,16 +530,35 @@ function validateAuthority(authority: KxmCoordinatorAuthority): KxmCoordinatorAu
|
|
|
459
530
|
const tools = authority.tools;
|
|
460
531
|
if (tools !== undefined) {
|
|
461
532
|
const lists = [tools.allow ?? [], tools.deny ?? []];
|
|
462
|
-
for (const list of lists)
|
|
533
|
+
for (const list of lists) {
|
|
534
|
+
for (const tool of list) requireIdentifier(tool, "tools");
|
|
535
|
+
if (new Set(list).size !== list.length) {
|
|
536
|
+
throw runtimeError("coordinator_authority_invalid", "tools", "tool lists must not repeat");
|
|
537
|
+
}
|
|
538
|
+
}
|
|
463
539
|
if (tools.preset !== undefined) requireIdentifier(tools.preset, "tools.preset");
|
|
464
540
|
}
|
|
541
|
+
// Sets are stored canonically: order and repeats carry no authority, and leaving
|
|
542
|
+
// them as supplied would let an equivalent ceiling masquerade as a rebind.
|
|
465
543
|
return {
|
|
466
544
|
repositoryAccess: authority.repositoryAccess,
|
|
467
|
-
effects,
|
|
468
|
-
...(tools !== undefined
|
|
545
|
+
effects: canonicalSet(effects),
|
|
546
|
+
...(tools !== undefined
|
|
547
|
+
? {
|
|
548
|
+
tools: {
|
|
549
|
+
...(tools.preset !== undefined ? { preset: tools.preset } : {}),
|
|
550
|
+
...(tools.allow !== undefined ? { allow: canonicalSet(tools.allow) } : {}),
|
|
551
|
+
...(tools.deny !== undefined ? { deny: canonicalSet(tools.deny) } : {}),
|
|
552
|
+
},
|
|
553
|
+
}
|
|
554
|
+
: {}),
|
|
469
555
|
};
|
|
470
556
|
}
|
|
471
557
|
|
|
558
|
+
function canonicalSet(values: readonly string[]): string[] {
|
|
559
|
+
return [...new Set(values)].sort();
|
|
560
|
+
}
|
|
561
|
+
|
|
472
562
|
function validateActor<T extends { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string }>(actor: T, field: string): T {
|
|
473
563
|
const kinds: string[] = ["human", "runtime", "hub", "agent", "adapter"];
|
|
474
564
|
if (!actor || typeof actor !== "object" || !kinds.includes(actor.kind)) {
|
|
@@ -506,18 +596,6 @@ function requireTimestamp(value: string, field: string): string {
|
|
|
506
596
|
return value;
|
|
507
597
|
}
|
|
508
598
|
|
|
509
|
-
function contextConfigRevision(context: KxmRuntimeContext): string {
|
|
510
|
-
const revision = (context as { configRevision?: string }).configRevision;
|
|
511
|
-
if (typeof revision === "string" && /^sha256:[a-f0-9]{64}$/.test(revision)) return revision;
|
|
512
|
-
// The context does not carry one: fingerprint its own identity inputs instead of
|
|
513
|
-
// inventing a revision, so drift across a reopen is still detectable.
|
|
514
|
-
return `sha256:${createHash("sha256").update(stableStringify({
|
|
515
|
-
projectId: context.projectId,
|
|
516
|
-
projectRoot: context.projectRoot,
|
|
517
|
-
homeRuntimeId: context.homeRuntimeId,
|
|
518
|
-
}), "utf8").digest("hex")}`;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
599
|
function stableStringify(value: unknown): string {
|
|
522
600
|
return JSON.stringify(sortDeep(value));
|
|
523
601
|
}
|
|
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
|
|
|
8
8
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
9
9
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
10
10
|
|
|
11
|
-
const VERSION = "0.7.
|
|
11
|
+
const VERSION = "0.7.50";
|
|
12
12
|
const inbox = new Map<string, MessageRecord>();
|
|
13
13
|
const notifiedInbox = new Set<string>();
|
|
14
14
|
let meshClient: HubClient | undefined;
|
|
@@ -365,7 +365,7 @@ export function syncHarnessMemory(repoRoot: string): { updated: string[]; create
|
|
|
365
365
|
|
|
366
366
|
// 2. CLAUDE.md
|
|
367
367
|
const claudePath = join(root, "CLAUDE.md");
|
|
368
|
-
const claudeHeader = `# KXM (Claude)\n\nFollow [\`AGENTS.md\`](AGENTS.md). Official phase tracking:\n[\`plans/implementation-plan.md\`](plans/implementation-plan.md#tracking-working-tree-not-a-release).\n\nYou are the **planner / architecture critic** unless the human explicitly asks\nyou to implement. Default writer is native Grok CLI (\`grok --model grok-4.6\`)
|
|
368
|
+
const claudeHeader = `# KXM (Claude)\n\nFollow [\`AGENTS.md\`](AGENTS.md). Official phase tracking:\n[\`plans/implementation-plan.md\`](plans/implementation-plan.md#tracking-working-tree-not-a-release).\n\nYou are the **planner / architecture critic** unless the human explicitly asks\nyou to implement. Default writer is native Grok CLI (\`grok --model grok-4.6\`) — a starting\nrotation, not a sole writer. If \`grok\` is logged out, never bill Grok through another harness;\nuse a relief route Tracking **admits**, or stop and name the limits hit. Your reviews are\nartifacts, not hub \`peer-reply\` evidence.\n`;
|
|
369
369
|
const claudeExisted = existsSync(claudePath);
|
|
370
370
|
if (updateHarnessDocument(claudePath, block, claudeHeader)) {
|
|
371
371
|
if (claudeExisted) updated.push("CLAUDE.md");
|
|
@@ -374,7 +374,7 @@ export function syncHarnessMemory(repoRoot: string): { updated: string[]; create
|
|
|
374
374
|
|
|
375
375
|
// 3. GEMINI.md
|
|
376
376
|
const geminiPath = join(root, "GEMINI.md");
|
|
377
|
-
const geminiHeader = `# KXM (Gemini / Antigravity)\n\nFollow [\`AGENTS.md\`](AGENTS.md). Official phase tracking:\n[\`plans/implementation-plan.md\`](plans/implementation-plan.md#tracking-working-tree-not-a-release).\n\nGoogle
|
|
377
|
+
const geminiHeader = `# KXM (Gemini / Antigravity)\n\nFollow [\`AGENTS.md\`](AGENTS.md). Official phase tracking:\n[\`plans/implementation-plan.md\`](plans/implementation-plan.md#tracking-working-tree-not-a-release).\n\nGoogle goes through the \`antigravity\` **Pi provider** (Tracking \u2192 Decided,\n2026-09-15); \`agy\` stays a harness catalog/helper entry, not the admission path.\nCurrent admissions come from Tracking and \`kxm harness list\`. Starting rotation\nremains Grok.\n`;
|
|
378
378
|
const geminiExisted = existsSync(geminiPath);
|
|
379
379
|
if (updateHarnessDocument(geminiPath, block, geminiHeader)) {
|
|
380
380
|
if (geminiExisted) updated.push("GEMINI.md");
|
|
@@ -234,6 +234,8 @@ export interface KxmRuntimeContext {
|
|
|
234
234
|
projectId: string;
|
|
235
235
|
homeRuntimeId: string;
|
|
236
236
|
eventStore: KxmRunEventStore;
|
|
237
|
+
/** Deterministic revision of the loaded project configuration (`sha256:...`). */
|
|
238
|
+
readonly configRevision: string;
|
|
237
239
|
/**
|
|
238
240
|
* Optional clock for **run-duration budget accounting only**. Budgets are
|
|
239
241
|
* measured from the log's `runningSince`; this answers "what time is it now"
|
|
@@ -306,6 +308,7 @@ export function openKxmRuntimeContext(
|
|
|
306
308
|
projectId: registration.projectId,
|
|
307
309
|
homeRuntimeId: registration.homeRuntimeId,
|
|
308
310
|
eventStore,
|
|
311
|
+
configRevision: bundle.configRevision,
|
|
309
312
|
...(options.budgetClock ? { budgetClock: options.budgetClock } : {}),
|
|
310
313
|
};
|
|
311
314
|
} catch (error) {
|
|
@@ -1255,14 +1255,14 @@ export class KxmRunEventStore {
|
|
|
1255
1255
|
return row ? intakeFromRow(row) : undefined;
|
|
1256
1256
|
}
|
|
1257
1257
|
|
|
1258
|
-
/** Intake rows in the given dispatch states,
|
|
1258
|
+
/** Intake rows in the given dispatch states, in arrival order (replay-safe). */
|
|
1259
1259
|
intakeInStates(projectId: string, states: readonly KxmIntakeMessageRow["dispatchState"][], limit = 100): KxmIntakeMessageRow[] {
|
|
1260
1260
|
if (states.length === 0) return [];
|
|
1261
1261
|
const placeholders = states.map(() => "?").join(", ");
|
|
1262
1262
|
const rows = this.database.prepare(`
|
|
1263
1263
|
SELECT * FROM intake_messages
|
|
1264
1264
|
WHERE project_id = ? AND dispatch_state IN (${placeholders})
|
|
1265
|
-
ORDER BY
|
|
1265
|
+
ORDER BY rowid ASC
|
|
1266
1266
|
LIMIT ?
|
|
1267
1267
|
`).all(projectId, ...states, limit) as unknown as IntakeSqlRow[];
|
|
1268
1268
|
return rows.map(intakeFromRow);
|
|
@@ -1554,10 +1554,19 @@ interface ControlSqlRow {
|
|
|
1554
1554
|
* whose index and payload disagree fails closed instead of trusting either.
|
|
1555
1555
|
*/
|
|
1556
1556
|
function coordinatorFromRow(row: CoordinatorSqlRow): KxmCoordinatorRow {
|
|
1557
|
-
const parsed = JSON.parse(row.record) as {
|
|
1557
|
+
const parsed = JSON.parse(row.record) as {
|
|
1558
|
+
coordinatorId?: string; projectId?: string; role?: string; channel?: string;
|
|
1559
|
+
ceilingHash?: string; configRevision?: string; boundAt?: string;
|
|
1560
|
+
};
|
|
1561
|
+
if (row.schema !== "kxm.coordinator.v1") {
|
|
1562
|
+
throw runtimeError("coordinator_record_divergent", row.coordinator_id, `unexpected coordinator schema ${row.schema}`);
|
|
1563
|
+
}
|
|
1558
1564
|
validateCoordinator(parsed, row.coordinator_id);
|
|
1559
1565
|
if (
|
|
1560
1566
|
parsed.coordinatorId !== row.coordinator_id
|
|
1567
|
+
|| parsed.projectId !== row.project_id
|
|
1568
|
+
|| parsed.role !== row.role
|
|
1569
|
+
|| parsed.channel !== row.channel
|
|
1561
1570
|
|| parsed.ceilingHash !== row.ceiling_hash
|
|
1562
1571
|
|| parsed.configRevision !== row.config_revision
|
|
1563
1572
|
|| parsed.boundAt !== row.bound_at
|
|
@@ -1577,10 +1586,19 @@ function coordinatorFromRow(row: CoordinatorSqlRow): KxmCoordinatorRow {
|
|
|
1577
1586
|
}
|
|
1578
1587
|
|
|
1579
1588
|
function intakeFromRow(row: IntakeSqlRow): KxmIntakeMessageRow {
|
|
1580
|
-
const parsed = JSON.parse(row.record) as {
|
|
1589
|
+
const parsed = JSON.parse(row.record) as {
|
|
1590
|
+
messageId?: string; projectId?: string; coordinatorId?: string; idempotencyKey?: string;
|
|
1591
|
+
contentHash?: string; receivedAt?: string; dispatch?: { state?: string };
|
|
1592
|
+
};
|
|
1593
|
+
if (row.schema !== "kxm.intake-message.v1") {
|
|
1594
|
+
throw runtimeError("intake_record_divergent", row.message_id, `unexpected intake schema ${row.schema}`);
|
|
1595
|
+
}
|
|
1581
1596
|
validateIntakeMessage(parsed, row.message_id);
|
|
1582
1597
|
if (
|
|
1583
1598
|
parsed.messageId !== row.message_id
|
|
1599
|
+
|| parsed.projectId !== row.project_id
|
|
1600
|
+
|| parsed.coordinatorId !== row.coordinator_id
|
|
1601
|
+
|| parsed.idempotencyKey !== row.idempotency_key
|
|
1584
1602
|
|| parsed.contentHash !== row.content_hash
|
|
1585
1603
|
|| parsed.receivedAt !== row.received_at
|
|
1586
1604
|
|| parsed.dispatch?.state !== row.dispatch_state
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"additionalProperties": false,
|
|
42
42
|
"allOf": [
|
|
43
43
|
{
|
|
44
|
-
"if": { "properties": { "classification": { "
|
|
45
|
-
"then": { "required": ["content"] },
|
|
46
|
-
"else": { "not": { "required": ["
|
|
44
|
+
"if": { "properties": { "classification": { "const": "secret" } }, "required": ["classification"] },
|
|
45
|
+
"then": { "required": ["contentOmittedReason"], "not": { "required": ["content"] } },
|
|
46
|
+
"else": { "required": ["content"], "not": { "required": ["contentOmittedReason"] } }
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
49
|
"$defs": {
|
|
@@ -62,6 +62,10 @@
|
|
|
62
62
|
"if": { "properties": { "state": { "const": "admitted" } }, "required": ["state"] },
|
|
63
63
|
"then": { "required": ["runId", "updatedAt"] }
|
|
64
64
|
},
|
|
65
|
+
{
|
|
66
|
+
"if": { "properties": { "state": { "not": { "const": "admitted" } } }, "required": ["state"] },
|
|
67
|
+
"then": { "not": { "required": ["runId"] } }
|
|
68
|
+
},
|
|
65
69
|
{
|
|
66
70
|
"if": { "properties": { "state": { "enum": ["held_paused", "refused"] } }, "required": ["state"] },
|
|
67
71
|
"then": { "required": ["reason"] }
|