Skip to content

Commit 9e40a86

Browse files
hyperpolymathclaude
andcommitted
feat(local-coord-mcp): reassignment suggestion engine (Task #14)
Engine emits candidate envelopes INTO the quarantine (server-origin, sender_idx=0xFE sentinel) for supervisor review via the existing coord_review + coord_approve / coord_reject flow. Never auto-modifies affinities. New FFI + tools: coord_set_declared_affinities(token, tags_csv) — self-reported strengths coord_scan_suggestions(token) — runs scan, enqueues candidates Scanner rules: overclaim: avg_confidence >= 80% AND effective_affinity < 30% -> op_kind=fyi promote: effective_affinity >= 70% AND tag not declared -> op_kind=fyi remove: effective_affinity <= 20% AND attempts >= 5 -> op_kind=clarify coord_report_outcome now takes an optional confidence_pct (8th arg, -1 for unset). Log format extends track_update with a trailing confidence byte (backward-compatible via length-check). coord_register accepts an optional declared_affinities array. coord_review adapter output adds an "origin" field ("peer" | "server-engine") so supervisors can tell engine-synthesised suggestions from real peer messages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3e9eae8 commit 9e40a86

6 files changed

Lines changed: 534 additions & 24 deletions

File tree

cartridges/local-coord-mcp/adapter/local_coord_adapter.zig

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,30 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
109109
}
110110
}
111111

112+
// Optional declared_affinities — an array of tag strings that the
113+
// peer self-reports as strengths. Stored as a CSV internally so the
114+
// reassignment engine (Task #14) can diff against track record.
115+
if (parsed.value.object.get("declared_affinities")) |decl_val| {
116+
if (decl_val == .array) {
117+
var csv_buf: [256]u8 = undefined;
118+
var csv_len: usize = 0;
119+
for (decl_val.array.items) |item| {
120+
if (item != .string) continue;
121+
const s = item.string;
122+
if (csv_len > 0 and csv_len < csv_buf.len) {
123+
csv_buf[csv_len] = ',';
124+
csv_len += 1;
125+
}
126+
const to_copy: usize = @min(s.len, csv_buf.len - csv_len);
127+
if (to_copy > 0) @memcpy(csv_buf[csv_len .. csv_len + to_copy], s[0..to_copy]);
128+
csv_len += to_copy;
129+
}
130+
if (csv_len > 0) {
131+
_ = ffi.coord_set_declared_affinities(&token, 16, &csv_buf, @intCast(csv_len));
132+
}
133+
}
134+
}
135+
112136
var token_hex: [32]u8 = undefined;
113137
const hex_chars = "0123456789abcdef";
114138
for (token, 0..) |b, i| {
@@ -374,8 +398,11 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
374398
const preview = rec[9 .. 9 + preview_n];
375399

376400
if (i > 0) w.writeAll(",") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
377-
std.fmt.format(w, "{{\"request_id\":{d},\"sender_idx\":{d},\"target_idx\":{d},\"risk_tier\":{d},\"msg_len\":{d},\"preview\":\"{s}\"}}", .{
378-
rid, sender_idx, target_idx_sign, risk_tier, mlen, preview,
401+
// sender_idx = 0xFE indicates a server-origin (engine-generated)
402+
// entry from coord_scan_suggestions (Task #14).
403+
const origin: []const u8 = if (sender_idx == 0xFE) "server-engine" else "peer";
404+
std.fmt.format(w, "{{\"request_id\":{d},\"origin\":\"{s}\",\"sender_idx\":{d},\"target_idx\":{d},\"risk_tier\":{d},\"msg_len\":{d},\"preview\":\"{s}\"}}", .{
405+
rid, origin, sender_idx, target_idx_sign, risk_tier, mlen, preview,
379406
}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
380407
}
381408
w.writeAll("]}") catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
@@ -429,6 +456,15 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
429456
const v = parsed.value.object.get("duration_ms") orelse break :blk 0;
430457
break :blk v.integer;
431458
};
459+
// confidence is optional; accept 0..1 float or 0..100 integer, -1 if absent.
460+
const confidence: i32 = blk: {
461+
const v = parsed.value.object.get("confidence") orelse break :blk -1;
462+
switch (v) {
463+
.float => |f| break :blk @intFromFloat(@min(@max(f * 100.0, 0.0), 100.0)),
464+
.integer => |i| break :blk @intCast(i),
465+
else => break :blk -1,
466+
}
467+
};
432468

433469
var token: [16]u8 = undefined;
434470
if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
@@ -450,13 +486,57 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
450486

451487
const tier: i32 = @intCast(tier_val.integer);
452488
const duration: i32 = @intCast(duration_val);
453-
const rc = ffi.coord_report_outcome(&token, 16, tag_str.ptr, @intCast(tag_str.len), outcome, duration, tier);
489+
const rc = ffi.coord_report_outcome(&token, 16, tag_str.ptr, @intCast(tag_str.len), outcome, duration, tier, confidence);
454490
if (rc == 0) return .{ .status = 200, .body = okJson(resp, "recorded") };
455491
if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") };
456492
if (rc == -2) return .{ .status = 400, .body = errJson(resp, "invalid args") };
457493
return .{ .status = 500, .body = errJson(resp, "report failed") };
458494
}
459495

496+
if (std.mem.eql(u8, tool, "coord_set_declared_affinities")) {
497+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
498+
defer parsed.deinit();
499+
const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") };
500+
const tags_val = parsed.value.object.get("tags") orelse return .{ .status = 400, .body = errJson(resp, "missing tags") };
501+
if (tags_val != .array) return .{ .status = 400, .body = errJson(resp, "tags must be an array of strings") };
502+
503+
var token: [16]u8 = undefined;
504+
if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
505+
506+
var csv_buf: [256]u8 = undefined;
507+
var csv_len: usize = 0;
508+
for (tags_val.array.items) |item| {
509+
if (item != .string) continue;
510+
const s = item.string;
511+
if (csv_len > 0 and csv_len < csv_buf.len) {
512+
csv_buf[csv_len] = ',';
513+
csv_len += 1;
514+
}
515+
const to_copy: usize = @min(s.len, csv_buf.len - csv_len);
516+
if (to_copy > 0) @memcpy(csv_buf[csv_len .. csv_len + to_copy], s[0..to_copy]);
517+
csv_len += to_copy;
518+
}
519+
520+
const rc = ffi.coord_set_declared_affinities(&token, 16, &csv_buf, @intCast(csv_len));
521+
if (rc == 0) return .{ .status = 200, .body = okJson(resp, "declared") };
522+
if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") };
523+
if (rc == -2) return .{ .status = 400, .body = errJson(resp, "declared affinities CSV too long") };
524+
return .{ .status = 500, .body = errJson(resp, "set failed") };
525+
}
526+
527+
if (std.mem.eql(u8, tool, "coord_scan_suggestions")) {
528+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
529+
defer parsed.deinit();
530+
const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") };
531+
var token: [16]u8 = undefined;
532+
if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
533+
534+
const n = ffi.coord_scan_suggestions(&token, 16);
535+
if (n == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") };
536+
const body_out = std.fmt.bufPrint(resp, "{{\"success\":true,\"suggestions_queued\":{d},\"hint\":\"use coord_review to inspect, coord_approve/coord_reject to act\"}}", .{n}) catch return .{ .status = 500, .body = errJson(resp, "buffer overflow") };
537+
return .{ .status = 200, .body = body_out };
538+
}
539+
460540
if (std.mem.eql(u8, tool, "coord_get_affinities")) {
461541
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
462542
defer parsed.deinit();

cartridges/local-coord-mcp/cartridge.json

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"tier": "Ayo",
2929
"tools": [
3030
{
31-
"description": "Register this instance as a coordination peer. Returns a peer ID and a session token for all subsequent calls. Optional `context` (alphanumeric + hyphen/underscore, max 32 bytes) disambiguates multiple windows of the same client_kind — peer_id becomes <kind>-<4hex>@<context>.",
31+
"description": "Register this instance as a coordination peer. Returns a peer ID and a session token for all subsequent calls. Optional `context` (alphanumeric + hyphen/underscore, max 32 bytes) disambiguates multiple windows of the same client_kind. Optional `declared_affinities` is an array of tag names the peer self-reports as strengths — feeds the reassignment engine (Task #14).",
3232
"inputSchema": {
3333
"properties": {
3434
"client_kind": {
@@ -45,6 +45,13 @@
4545
"description": "Optional per-window disambiguator (repo name, tty label). Alphanumeric/hyphen/underscore only, max 32 chars.",
4646
"type": "string"
4747
},
48+
"declared_affinities": {
49+
"description": "Optional array of tag strings the peer claims as strengths (e.g. ['proof-analysis', 'supervision']). Feeds Task #14 reassignment engine.",
50+
"items": {
51+
"type": "string"
52+
},
53+
"type": "array"
54+
},
4855
"role": {
4956
"description": "Optional requested role. supervisor is ALWAYS rejected here — promote via coord_promote_to_supervisor. Default derived from client_kind (claude->executor, others->supervised).",
5057
"enum": [
@@ -325,9 +332,15 @@
325332
"name": "coord_reject"
326333
},
327334
{
328-
"description": "Report the outcome of a claim or attempted op against an affinity tag. Aggregated into the track record keyed on client_kind (survives peer restart). Feeds effective_affinity + reassignment suggestions.",
335+
"description": "Report the outcome of a claim or attempted op against an affinity tag. Aggregated into the track record keyed on client_kind (survives peer restart). Feeds effective_affinity + reassignment suggestions. Task #14: optional `confidence` 0.0-1.0 feeds the overclaim detector.",
329336
"inputSchema": {
330337
"properties": {
338+
"confidence": {
339+
"description": "Self-assessed confidence at claim time (0.0-1.0). Feeds overclaim detection.",
340+
"maximum": 1,
341+
"minimum": 0,
342+
"type": "number"
343+
},
331344
"duration_ms": {
332345
"description": "Wall-time duration of the op in ms (optional, default 0)",
333346
"minimum": 0,
@@ -382,7 +395,47 @@
382395
"type": "object"
383396
},
384397
"name": "coord_get_affinities"
398+
},
399+
{
400+
"description": "Set this peer's declared affinities (self-reported strengths). Replaces any existing list. Task #14 diffs against effective_affinity to detect overclaim/promote/remove outliers.",
401+
"inputSchema": {
402+
"properties": {
403+
"tags": {
404+
"description": "Array of tag names (e.g. ['proof-analysis', 'supervision']). Serialised as CSV internally (max 256 bytes total).",
405+
"items": {
406+
"type": "string"
407+
},
408+
"type": "array"
409+
},
410+
"token": {
411+
"description": "Session token from coord_register",
412+
"type": "string"
413+
}
414+
},
415+
"required": [
416+
"token",
417+
"tags"
418+
],
419+
"type": "object"
420+
},
421+
"name": "coord_set_declared_affinities"
422+
},
423+
{
424+
"description": "Run the reassignment-suggestion scanner (Task #14). Scans track-record aggregates vs declared_affinities; enqueues candidate envelopes in the quarantine for supervisor review. Rules: overclaim (high avg_confidence + low effective_affinity), promote (high effective_affinity on undeclared tag), remove (low effective_affinity with >=5 attempts). Supervisor reviews via coord_review / approves via coord_approve / rejects via coord_reject — never auto-modifies.",
425+
"inputSchema": {
426+
"properties": {
427+
"token": {
428+
"description": "Session token from coord_register",
429+
"type": "string"
430+
}
431+
},
432+
"required": [
433+
"token"
434+
],
435+
"type": "object"
436+
},
437+
"name": "coord_scan_suggestions"
385438
}
386439
],
387-
"version": "0.4.0"
440+
"version": "0.5.0"
388441
}

cartridges/local-coord-mcp/ffi/coord_durability.zig

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,10 @@ pub fn logAudit(kind: u8, detail: []const u8) void {
327327
}
328328

329329
/// TRACK_UPDATE — client_kind:u8 outcome:u8 risk_tier:u8 duration_ms:u32
330-
/// timestamp_ms:u64 tag_len:u8 tag[tag_len]
330+
/// timestamp_ms:u64 tag_len:u8 tag[tag_len] confidence_pct:u8
331+
///
332+
/// confidence_pct is 0..100, or 255 = unset. Always the last byte so old
333+
/// decoders (16+tag.len) can still parse the leading fields.
331334
///
332335
/// DD-29: keyed on client_kind (not peer_id/suffix) so the track record
333336
/// survives peer crash+restart — a fresh peer of the same client_kind
@@ -339,17 +342,19 @@ pub fn logTrackUpdate(
339342
duration_ms: u32,
340343
timestamp_ms: u64,
341344
tag: []const u8,
345+
confidence_pct: u8,
342346
) void {
343347
if (tag.len > 64) return;
344-
var buf: [16 + 64]u8 = undefined;
348+
var buf: [17 + 64]u8 = undefined;
345349
buf[0] = client_kind;
346350
buf[1] = outcome;
347351
buf[2] = risk_tier;
348352
std.mem.writeInt(u32, buf[3..7], duration_ms, .little);
349353
std.mem.writeInt(u64, buf[7..15], timestamp_ms, .little);
350354
buf[15] = @intCast(tag.len);
351355
if (tag.len > 0) @memcpy(buf[16 .. 16 + tag.len], tag);
352-
append(.track_update, buf[0 .. 16 + tag.len]);
356+
buf[16 + tag.len] = confidence_pct;
357+
append(.track_update, buf[0 .. 17 + tag.len]);
353358
}
354359

355360
// ═══════════════════════════════════════════════════════════════════════
@@ -463,18 +468,21 @@ pub const TrackUpdate = struct {
463468
duration_ms: u32,
464469
timestamp_ms: u64,
465470
tag: []const u8,
471+
confidence_pct: u8, // 255 = unset (old event or never reported)
466472
};
467473
pub fn decodeTrackUpdate(p: []const u8) ?TrackUpdate {
468474
if (p.len < 16) return null;
469475
const n: usize = p[15];
470476
if (p.len < 16 + n) return null;
477+
const conf: u8 = if (p.len >= 17 + n) p[16 + n] else 255;
471478
return .{
472479
.client_kind = p[0],
473480
.outcome = p[1],
474481
.risk_tier = p[2],
475482
.duration_ms = std.mem.readInt(u32, p[3..7], .little),
476483
.timestamp_ms = std.mem.readInt(u64, p[7..15], .little),
477484
.tag = p[16 .. 16 + n],
485+
.confidence_pct = conf,
478486
};
479487
}
480488

@@ -611,7 +619,7 @@ test "replay decodes every event type" {
611619
logQuarApprove(42);
612620
logQuarReject(43, "confabulated path");
613621
logAudit(1, "tier3-from-supervised");
614-
logTrackUpdate(0, 1, 2, 1234, 1_700_000_000_000, "proof-analysis");
622+
logTrackUpdate(0, 1, 2, 1234, 1_700_000_000_000, "proof-analysis", 85);
615623
logPeerRemove(0);
616624
close();
617625

@@ -642,4 +650,5 @@ test "replay decodes every event type" {
642650
try std.testing.expectEqual(@as(u32, 1234), tr.duration_ms);
643651
try std.testing.expectEqual(@as(u64, 1_700_000_000_000), tr.timestamp_ms);
644652
try std.testing.expectEqualSlices(u8, "proof-analysis", tr.tag);
653+
try std.testing.expectEqual(@as(u8, 85), tr.confidence_pct);
645654
}

0 commit comments

Comments
 (0)