@kontextmind/kxm 0.7.54 → 0.7.55
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 +24 -0
- package/docs/contracts/lifecycles.md +16 -0
- package/docs/test-matrix.md +1 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime.js +32 -16
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/pi-producer.ts +61 -17
package/CHANGELOG.md
CHANGED
|
@@ -35,6 +35,30 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
35
35
|
|
|
36
36
|
### Changed
|
|
37
37
|
|
|
38
|
+
- **A Pi producer reply can no longer mint its own success.** `determineOutcome` scanned the
|
|
39
|
+
reply for any declared outcome *word* and, failing that, returned `passed`. So
|
|
40
|
+
`"the gate did not pass, so I would not call this passed"` settled the step as passed — the
|
|
41
|
+
word was all it took — and an empty or prose-only reply was passed by default. Only a declared
|
|
42
|
+
result counts now: a reply that is one JSON object, or prose carrying an explicit
|
|
43
|
+
`{"outcome": "…"}` block, and only when the step declares that outcome. Anything else is
|
|
44
|
+
`failed`; an undeclared value still lands in `outcome_unknown` and terminates as `failed`, so a
|
|
45
|
+
step without a `failed` transition cannot pass on a bad reply either. This matches the rule the
|
|
46
|
+
one-shot producer already enforced, and `docs/contracts/lifecycles.md` now states it where
|
|
47
|
+
`result_recorded` is defined. Three usage-capture fixtures that had been replying in prose now
|
|
48
|
+
declare their result, which is what they were always supposed to do; the new test in
|
|
49
|
+
`test/core/pi-producer.test.ts` covers prose, empty, out-of-vocabulary, and both accepted
|
|
50
|
+
structured shapes. Review of that first cut found two more ways to mint success, both now
|
|
51
|
+
closed: a result block was matched **anywhere** in the reply, so
|
|
52
|
+
`Example: {"outcome": "passed"}. Actual result: {"outcome": "failed"}` returned `passed`;
|
|
53
|
+
a declaration is now a standalone JSON object — the whole reply, or one object on its own
|
|
54
|
+
line, with the **last** such object winning so an illustration cannot outrank the answer, and
|
|
55
|
+
the whole reply settling `failed` when anything after that line still looks like an outcome
|
|
56
|
+
key, because at that point the producer cannot tell which declaration was meant.
|
|
57
|
+
And cancellation fell through to `allowedOutcomes[0]` when a step declared neither
|
|
58
|
+
`cancelled` nor `failed`, so aborting a `passed`-only step reported `passed`; a cancel now
|
|
59
|
+
reports `cancelled` unconditionally and the engine terminates it `failed` when the step
|
|
60
|
+
does not declare that outcome.
|
|
61
|
+
|
|
38
62
|
- **`kxm migrate` is gone, and so is the state that only it could unlock.** Deleting the
|
|
39
63
|
migration lanes left a converter with nothing to convert into: `plugins/kxm/src/migrate.ts`
|
|
40
64
|
(1,848 lines), its 1,598-line suite, the `migrate plan|apply|verify` commands, the
|
|
@@ -105,6 +105,22 @@ created → accepted → dispatched → executing → result_recorded → termin
|
|
|
105
105
|
| `blocked_uncertain` | A dependent effect cannot be reconciled safely |
|
|
106
106
|
| `terminal` | The logical assignment outcome is final and immutable: passed, failed, or cancelled |
|
|
107
107
|
|
|
108
|
+
A producer reply becomes a terminal outcome **only** through a declared result: the reply is
|
|
109
|
+
one JSON object, or it carries a `{"outcome": "…"}` object **on a line of its own**. With more
|
|
110
|
+
than one such line the last one is the answer; if anything after it still looks like an outcome
|
|
111
|
+
key — an inline `Actual result: {"outcome": "failed"}`, a pretty-printed object, a second
|
|
112
|
+
mention — the reply is **ambiguous and settles `failed`**, because guessing which declaration
|
|
113
|
+
was meant is the behaviour this rule removes. Ordinary trailing prose (a sign-off, a token
|
|
114
|
+
count) does not disturb a declared result.
|
|
115
|
+
|
|
116
|
+
Naming an outcome *word* anywhere in a reply is not a result — `"the gate did not pass, so I
|
|
117
|
+
would not call this passed"` must not advance a step — and an empty or unstructured reply is
|
|
118
|
+
never treated as success. A declared outcome outside the step's declared set is not accepted
|
|
119
|
+
either:
|
|
120
|
+
the assignment is recorded `outcome_unknown` and terminates as `failed`, which is also what
|
|
121
|
+
happens when a step declares no `failed` transition. Producers do not guess on the model's
|
|
122
|
+
behalf, and no fallback path mints `passed`.
|
|
123
|
+
|
|
108
124
|
`assignmentId` remains stable. A retry moves the nonterminal assignment through
|
|
109
125
|
`retry_pending` to `accepted`, creates a new `attemptId`, and never rewrites or
|
|
110
126
|
exits the previous attempt's terminal state. The effective
|
package/docs/test-matrix.md
CHANGED
|
@@ -13,6 +13,7 @@ npm run verify
|
|
|
13
13
|
|
|
14
14
|
| Feature | Automated evidence |
|
|
15
15
|
|---|---|
|
|
16
|
+
| Structured-result settlement only: outcome words in prose, empty replies, and outcomes outside the step's declared set all fail closed; a declared JSON object and a JSON result block inside prose both settle, and the engine records `outcome_unknown` and terminates `failed` | `test/core/pi-producer.test.ts`, `test/core/engine.test.ts` |
|
|
16
17
|
| Health, readiness, metrics, request IDs, security headers | `test/core/hub-api.test.ts` |
|
|
17
18
|
| Shared and per-project authentication, project isolation | `test/core/hub-api.test.ts` |
|
|
18
19
|
| Registration, discovery, presence, stale detection, identity resumption | `test/core/hub-api.test.ts`, `test/core/hub.test.ts` |
|
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.55",
|
|
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",
|
|
@@ -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.55";
|
|
17125
17125
|
var inbox = /* @__PURE__ */ new Map();
|
|
17126
17126
|
var notifiedInbox = /* @__PURE__ */ new Set();
|
|
17127
17127
|
var meshClient;
|
|
@@ -27913,20 +27913,37 @@ function extractText(content) {
|
|
|
27913
27913
|
}
|
|
27914
27914
|
return "";
|
|
27915
27915
|
}
|
|
27916
|
-
function
|
|
27917
|
-
|
|
27918
|
-
|
|
27919
|
-
|
|
27920
|
-
|
|
27921
|
-
|
|
27922
|
-
for (const outcome of allowedOutcomes) {
|
|
27923
|
-
const regex = new RegExp(`\\b${outcome}\\b`, "i");
|
|
27924
|
-
if (regex.test(normalized)) {
|
|
27925
|
-
return outcome;
|
|
27926
|
-
}
|
|
27916
|
+
function asJsonObject2(text) {
|
|
27917
|
+
try {
|
|
27918
|
+
const parsed = JSON.parse(text);
|
|
27919
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
27920
|
+
} catch {
|
|
27921
|
+
return void 0;
|
|
27927
27922
|
}
|
|
27928
|
-
|
|
27929
|
-
|
|
27923
|
+
}
|
|
27924
|
+
function outcomeField(value) {
|
|
27925
|
+
const outcome = value.outcome;
|
|
27926
|
+
return typeof outcome === "string" ? outcome : void 0;
|
|
27927
|
+
}
|
|
27928
|
+
function declaredOutcomeOf(text) {
|
|
27929
|
+
const trimmed = text.trim();
|
|
27930
|
+
if (!trimmed) return void 0;
|
|
27931
|
+
const whole = asJsonObject2(trimmed);
|
|
27932
|
+
if (whole) return outcomeField(whole);
|
|
27933
|
+
const lines = trimmed.split(/\r?\n/);
|
|
27934
|
+
let declaration;
|
|
27935
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
27936
|
+
const outcome = outcomeField(asJsonObject2(lines[index].trim()) ?? {});
|
|
27937
|
+
if (outcome !== void 0) declaration = { index, outcome };
|
|
27938
|
+
}
|
|
27939
|
+
if (!declaration) return void 0;
|
|
27940
|
+
const tail = lines.slice(declaration.index + 1).join("\n");
|
|
27941
|
+
if (/"outcome"\s*:/.test(tail)) return void 0;
|
|
27942
|
+
return declaration.outcome;
|
|
27943
|
+
}
|
|
27944
|
+
function determineOutcome2(text, allowedOutcomes) {
|
|
27945
|
+
const declared = declaredOutcomeOf(text);
|
|
27946
|
+
return declared !== void 0 && allowedOutcomes.includes(declared) ? declared : "failed";
|
|
27930
27947
|
}
|
|
27931
27948
|
var PiSession = class {
|
|
27932
27949
|
key;
|
|
@@ -28056,7 +28073,7 @@ var PiSession = class {
|
|
|
28056
28073
|
const isAborted = active.aborted || active.signal?.aborted;
|
|
28057
28074
|
let outcome;
|
|
28058
28075
|
if (isAborted) {
|
|
28059
|
-
outcome =
|
|
28076
|
+
outcome = "cancelled";
|
|
28060
28077
|
} else {
|
|
28061
28078
|
outcome = determineOutcome2(active.text, active.allowedOutcomes);
|
|
28062
28079
|
}
|
|
@@ -28108,8 +28125,7 @@ var PiSession = class {
|
|
|
28108
28125
|
throw new Error("pi_session_busy");
|
|
28109
28126
|
}
|
|
28110
28127
|
if (signal?.aborted) {
|
|
28111
|
-
|
|
28112
|
-
return { outcome, text: "aborted", usage: {} };
|
|
28128
|
+
return { outcome: "cancelled", text: "aborted", usage: {} };
|
|
28113
28129
|
}
|
|
28114
28130
|
this.status = "busy";
|
|
28115
28131
|
return new Promise((resolve8, reject) => {
|
package/plugins/kxm/package.json
CHANGED
|
@@ -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.55";
|
|
12
12
|
const inbox = new Map<string, MessageRecord>();
|
|
13
13
|
const notifiedInbox = new Set<string>();
|
|
14
14
|
let meshClient: HubClient | undefined;
|
|
@@ -82,20 +82,62 @@ function extractText(content: unknown): string {
|
|
|
82
82
|
return "";
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
function asJsonObject(text: string): Record<string, unknown> | undefined {
|
|
86
|
+
try {
|
|
87
|
+
const parsed: unknown = JSON.parse(text);
|
|
88
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function outcomeField(value: Record<string, unknown>): string | undefined {
|
|
95
|
+
const outcome = value.outcome;
|
|
96
|
+
return typeof outcome === "string" ? outcome : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The result a reply actually declares, or `undefined` when it declares none.
|
|
101
|
+
*
|
|
102
|
+
* Two shapes count: the whole reply is one JSON object, or a **standalone** result object on a
|
|
103
|
+
* line of its own — and when there is more than one of those, the **last** one wins, because
|
|
104
|
+
* that is where a reply puts its answer after showing an example. If anything in the tail after
|
|
105
|
+
* that declaration still looks like an outcome key, the reply is **ambiguous and settles
|
|
106
|
+
* `failed`**. What is deliberately not accepted: an outcome *word* anywhere in prose, and an
|
|
107
|
+
* object embedded mid-sentence, so
|
|
108
|
+
* `Example: {"outcome": "passed"}. Actual result: {"outcome": "failed"}` declares nothing at
|
|
109
|
+
* all and settles as `failed` rather than letting the illustration outrank the answer.
|
|
110
|
+
*/
|
|
111
|
+
function declaredOutcomeOf(text: string): string | undefined {
|
|
112
|
+
const trimmed = text.trim();
|
|
113
|
+
if (!trimmed) return undefined;
|
|
114
|
+
const whole = asJsonObject(trimmed);
|
|
115
|
+
if (whole) return outcomeField(whole);
|
|
116
|
+
const lines = trimmed.split(/\r?\n/);
|
|
117
|
+
let declaration: { index: number; outcome: string } | undefined;
|
|
118
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
119
|
+
const outcome = outcomeField(asJsonObject(lines[index]!.trim()) ?? {});
|
|
120
|
+
if (outcome !== undefined) declaration = { index, outcome };
|
|
96
121
|
}
|
|
97
|
-
if (
|
|
98
|
-
|
|
122
|
+
if (!declaration) return undefined;
|
|
123
|
+
// Ambiguity after the declaration fails closed. Anything in the tail that still looks like
|
|
124
|
+
// an outcome key — an inline `Actual result: {"outcome": "failed"}` on the next line, a
|
|
125
|
+
// pretty-printed object, or a second mention — means we cannot tell which one the reply is
|
|
126
|
+
// reporting, and guessing is exactly the behaviour this function exists to remove. Trailing
|
|
127
|
+
// prose that says nothing about outcomes is fine, which is what lets a real reply put its
|
|
128
|
+
// usage or sign-off after the result block.
|
|
129
|
+
const tail = lines.slice(declaration.index + 1).join("\n");
|
|
130
|
+
if (/"outcome"\s*:/.test(tail)) return undefined;
|
|
131
|
+
return declaration.outcome;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
|
|
135
|
+
// Structured result only. What does **not** count: an outcome *word* anywhere in the text —
|
|
136
|
+
// "the tests did not pass" used to settle a step as `passed` — and an empty or unstructured
|
|
137
|
+
// reply, which used to default to success. Undeclared here means `failed`; if the step does
|
|
138
|
+
// not declare `failed` the engine records `outcome_unknown` and terminates as `failed` anyway.
|
|
139
|
+
const declared = declaredOutcomeOf(text);
|
|
140
|
+
return declared !== undefined && allowedOutcomes.includes(declared) ? declared : "failed";
|
|
99
141
|
}
|
|
100
142
|
|
|
101
143
|
export class PiSession {
|
|
@@ -255,9 +297,11 @@ export class PiSession {
|
|
|
255
297
|
const isAborted = active.aborted || active.signal?.aborted;
|
|
256
298
|
let outcome: string;
|
|
257
299
|
if (isAborted) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
300
|
+
// A cancel is a cancel. The old chain fell through to `allowedOutcomes[0]` when the
|
|
301
|
+
// step declared neither `cancelled` nor `failed`, so aborting a step whose only
|
|
302
|
+
// declared outcome was `passed` reported `passed`. Returning `cancelled` instead lets
|
|
303
|
+
// the engine record `outcome_unknown` and terminate `failed` — never a borrowed success.
|
|
304
|
+
outcome = "cancelled";
|
|
261
305
|
} else {
|
|
262
306
|
outcome = determineOutcome(active.text, active.allowedOutcomes);
|
|
263
307
|
}
|
|
@@ -316,8 +360,8 @@ export class PiSession {
|
|
|
316
360
|
throw new Error("pi_session_busy");
|
|
317
361
|
}
|
|
318
362
|
if (signal?.aborted) {
|
|
319
|
-
|
|
320
|
-
return { outcome, text: "aborted", usage: {} };
|
|
363
|
+
// Same rule as the in-flight abort: never fall back to the first declared outcome.
|
|
364
|
+
return { outcome: "cancelled", text: "aborted", usage: {} };
|
|
321
365
|
}
|
|
322
366
|
|
|
323
367
|
this.status = "busy";
|