@nanobpm/nano-workforce 0.176.1 → 0.178.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -121,13 +121,31 @@ export interface McpHarness {
121
121
  /** The underlying booted app — exposed for a slice that needs to seed/inspect the app DB or drive
122
122
  * an operator-only (`x-mcp`-excluded) cleanup route the MCP surface does not expose. */
123
123
  readonly app: TestApp;
124
- /** The negotiated MCP session id (the captured `Mcp-Session-Id`). */
124
+ /** The CURRENT negotiated MCP session id (the captured `Mcp-Session-Id`). Tracks the live session,
125
+ * so after {@link McpHarness.reinitialize} it reflects the NEW id, not the original. */
125
126
  readonly sessionId: string;
126
127
  /** `tools/list` — the projected tool catalogue (app operations + framework debug tools). */
127
128
  listTools(): Promise<McpTool[]>;
128
129
  /** `tools/call` — invoke a tool by name with its argument object. Optional `extraHeaders` are
129
130
  * overlaid on the POST (e.g. an `x-hook-secret` shared-secret credential for a gated mutation). */
130
131
  callTool(name: string, args?: Record<string, unknown>, extraHeaders?: Record<string, string>): Promise<McpToolResult>;
132
+ /** `tools/call` against an EXPLICIT session id (not the harness's live one) — used to exercise the
133
+ * session-loss path: a call carrying a stale/unknown/deleted `mcp-session-id` must be refused with
134
+ * the runtime's `-32000` "no valid session id" error (issue #715, gap 1 self-heal regression). */
135
+ callToolAs(sessionId: string, name: string, args?: Record<string, unknown>): Promise<McpToolResult>;
136
+ /** Re-run the full client handshake (`initialize` → `notifications/initialized`), minting a FRESH
137
+ * session and adopting it as the harness's live session. This is the client-side SELF-HEAL a real
138
+ * MCP client performs after its session is lost (timeout, idle drop, proxy reset, LRU eviction):
139
+ * the pinned runtime is session-stateful (a stateless/resumable transport is tracked upstream in
140
+ * nano-ide#488), so re-initialising is how a client recovers the surface. Returns the new id. */
141
+ reinitialize(): Promise<string>;
142
+ /** End a session server-side via the transport's `DELETE` (the spec session-termination verb). With
143
+ * no argument, ends the harness's current session; pass an id to end a specific one. After this the
144
+ * ended id is unknown to the server, so a subsequent {@link McpHarness.callToolAs} with it is
145
+ * refused `-32000`. Optional `extraHeaders` are overlaid on the DELETE (e.g. an `x-hook-secret`
146
+ * shared-secret credential) exactly as {@link McpHarness.callTool} does, so this helper stays
147
+ * usable on a shared-secret-guarded surface. Returns the DELETE's transport status. */
148
+ deleteSession(sessionId?: string, extraHeaders?: Record<string, string>): Promise<number>;
131
149
  /** A raw JSON-RPC request against `/app/mcp` (escape hatch for a bespoke case). `params` omitted →
132
150
  * no `params` field; a `notifications/*` method is sent as a notification (no `id`, no response). */
133
151
  rpc(method: string, params?: unknown): Promise<McpRpcResult>;
@@ -221,9 +239,11 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
221
239
  rmSync(dbDir, { recursive: true, force: true });
222
240
  };
223
241
 
224
- let sessionId: string;
225
- try {
226
- // 1. initialize capture the runtime-minted session id.
242
+ /** Run the full client handshake against the live surface and return the freshly-minted session id:
243
+ * `initialize` (capture the `Mcp-Session-Id`) → `notifications/initialized`. Reused by the initial
244
+ * boot AND by {@link McpHarness.reinitialize} so the self-heal path exercises the SAME real
245
+ * handshake, never a shortcut. */
246
+ const doInitialize = async (): Promise<string> => {
227
247
  const initRes = await rpc("initialize", {
228
248
  protocolVersion: PROTOCOL_VERSION,
229
249
  capabilities: {},
@@ -240,10 +260,36 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
240
260
  `MCP initialize returned no ${SESSION_HEADER} header — headers: ${JSON.stringify(initRes.headers)}`,
241
261
  );
242
262
  }
243
- sessionId = mintedId;
263
+ // notifications/initialized — the client's post-init notification (no response expected).
264
+ await rpc("notifications/initialized", undefined, mintedId);
265
+ return mintedId;
266
+ };
267
+
268
+ /** Parse a `tools/call` JSON-RPC envelope into the client-visible {@link McpToolResult}. Shared by
269
+ * `callTool` and `callToolAs` so both surface a JSON-RPC-level error (e.g. `-32000` no-session) and
270
+ * a tool-level `isError` identically. */
271
+ const parseCallResult = (res: McpRpcResult): McpToolResult => {
272
+ const body = res.body as
273
+ | { result?: { isError?: boolean; content?: Array<{ type: string; text?: string }> }; error?: { message?: string } }
274
+ | undefined;
275
+ if (body?.error) {
276
+ const text = body.error.message ?? JSON.stringify(body.error);
277
+ return { isError: true, text, json: safeParse(text), httpStatus: res.httpStatus, raw: body };
278
+ }
279
+ const first = body?.result?.content?.find((c) => c.type === "text");
280
+ const text = first?.text ?? "";
281
+ return {
282
+ isError: body?.result?.isError === true,
283
+ text,
284
+ json: safeParse(text),
285
+ httpStatus: res.httpStatus,
286
+ raw: body,
287
+ };
288
+ };
244
289
 
245
- // 2. notifications/initialized — the client's post-init notification (no response expected).
246
- await rpc("notifications/initialized", undefined, sessionId);
290
+ let currentSessionId: string;
291
+ try {
292
+ currentSessionId = await doInitialize();
247
293
  } catch (err) {
248
294
  await teardown();
249
295
  throw err;
@@ -252,10 +298,12 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
252
298
  let stopped = false;
253
299
  const harness: McpHarness = {
254
300
  app,
255
- sessionId,
256
- rpc: (method, params) => rpc(method, params, sessionId),
301
+ get sessionId(): string {
302
+ return currentSessionId;
303
+ },
304
+ rpc: (method, params) => rpc(method, params, currentSessionId),
257
305
  async listTools(): Promise<McpTool[]> {
258
- const res = await rpc("tools/list", {}, sessionId);
306
+ const res = await rpc("tools/list", {}, currentSessionId);
259
307
  const body = res.body as { result?: { tools?: McpTool[] }; error?: unknown } | undefined;
260
308
  if (!body?.result?.tools) {
261
309
  throw new Error(`tools/list returned no result.tools: ${JSON.stringify(body)}`);
@@ -263,25 +311,30 @@ export async function bootMcpHarness(opts: BootMcpHarnessOptions = {}): Promise<
263
311
  return body.result.tools;
264
312
  },
265
313
  async callTool(name, args = {}, extraHeaders): Promise<McpToolResult> {
266
- const res = await rpc("tools/call", { name, arguments: args }, sessionId, extraHeaders);
267
- const body = res.body as
268
- | { result?: { isError?: boolean; content?: Array<{ type: string; text?: string }> }; error?: { message?: string } }
269
- | undefined;
270
- if (body?.error) {
271
- // A JSON-RPC-level error (e.g. an unknown tool name / protocol error) distinct from a
272
- // tool-level `isError` door failure. Surface it as an errored result carrying the message.
273
- const text = body.error.message ?? JSON.stringify(body.error);
274
- return { isError: true, text, json: safeParse(text), httpStatus: res.httpStatus, raw: body };
275
- }
276
- const first = body?.result?.content?.find((c) => c.type === "text");
277
- const text = first?.text ?? "";
278
- return {
279
- isError: body?.result?.isError === true,
280
- text,
281
- json: safeParse(text),
282
- httpStatus: res.httpStatus,
283
- raw: body,
314
+ return parseCallResult(await rpc("tools/call", { name, arguments: args }, currentSessionId, extraHeaders));
315
+ },
316
+ async callToolAs(sessionId, name, args = {}): Promise<McpToolResult> {
317
+ return parseCallResult(await rpc("tools/call", { name, arguments: args }, sessionId));
318
+ },
319
+ async reinitialize(): Promise<string> {
320
+ currentSessionId = await doInitialize();
321
+ return currentSessionId;
322
+ },
323
+ async deleteSession(sessionId = currentSessionId, extraHeaders): Promise<number> {
324
+ const headers: Record<string, string> = {
325
+ accept: "application/json, text/event-stream",
284
326
  };
327
+ // Overlay caller headers FIRST, then set the session header authoritatively — a caller passing
328
+ // auth headers (e.g. `x-hook-secret`) must not clobber the `mcp-session-id` being terminated.
329
+ if (extraHeaders) Object.assign(headers, extraHeaders);
330
+ headers[SESSION_HEADER] = sessionId;
331
+ const res = await app.ui.call({
332
+ method: "DELETE",
333
+ path: MCP_PATH,
334
+ headers,
335
+ body: "",
336
+ });
337
+ return res.status ?? 200;
285
338
  },
286
339
  async stop(): Promise<void> {
287
340
  if (stopped) return;
@@ -12,7 +12,9 @@ import { encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
12
12
  import type { SqliteDb } from "@nanobpm/agentic/presence";
13
13
  import type { AppApi, DataLayer } from "@nanobpm/urban";
14
14
  import { assert, assertEquals } from "#test-assert";
15
+ import { currentClaimRegistry } from "../app/agentic/claim-registry.ts";
15
16
  import { currentCorrelation } from "../app/agentic/correlation.ts";
17
+ import { family as claimFamily } from "../app/agentic/families/claim.family.ts";
16
18
  import { family as correlationFamily } from "../app/agentic/families/correlation.family.ts";
17
19
  import { family } from "../app/agentic/families/presence.family.ts";
18
20
  import type { AgenticContext } from "../app/agentic/registry.ts";
@@ -154,7 +156,32 @@ test("shared-secret guard rejects a missing secret when configured", async () =>
154
156
  }
155
157
  });
156
158
 
157
- test("H6: with the correlation family mounted, jobKeys populate, stream repoints, and correlations are reported", async () => {
159
+ test("#713: a claim populates jobKeys and repoints the drill stream with ZERO transcript (claim is the visibility source)", async () => {
160
+ const hub = await mountPresence(memSqlite());
161
+ const ctx: AgenticContext = { hub, registry: hub.registry, transport: undefined as never, data: undefined, log: noopLog() };
162
+ claimFamily.mount(ctx);
163
+ const claims = currentClaimRegistry();
164
+ assert(claims !== undefined, "the claim family installs the singleton");
165
+ // An explicit claim — no relay produce frame, no correlation link, zero transcript.
166
+ claims.claim("wk-a", "8420");
167
+ try {
168
+ const res = (await handler(input(), app)) as {
169
+ status: number;
170
+ body: { workers: Array<Record<string, unknown>>; correlations: unknown[] };
171
+ };
172
+ assertEquals(res.status, 200);
173
+ const w = res.body.workers[0];
174
+ assertEquals(w.jobKeys, ["8420"], "the claim registry feeds the jobKeys seam");
175
+ assertEquals(w.stream, "job:8420", "the drill stream repoints at the claimed job, keyed by the claim");
176
+ assertEquals(res.body.correlations.length, 0, "no correlation context until a terminal lands (drill-in only)");
177
+ } finally {
178
+ claimFamily.teardown?.();
179
+ family.teardown?.();
180
+ await hub.close();
181
+ }
182
+ });
183
+
184
+ test("#713: the correlation registry is demoted to drill-in context — a link alone no longer feeds jobKeys", async () => {
158
185
  const hub = await mountPresence(memSqlite());
159
186
  correlationFamily.mount({
160
187
  hub,
@@ -165,6 +192,7 @@ test("H6: with the correlation family mounted, jobKeys populate, stream repoints
165
192
  });
166
193
  const correlation = currentCorrelation();
167
194
  assert(correlation !== undefined, "the correlation family installs the singleton");
195
+ // A correlation link (drill-in context) WITHOUT a claim: visibility must NOT light up from it.
168
196
  correlation.link("wk-a", "6494", { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "o/r#142" });
169
197
  try {
170
198
  const res = (await handler(input(), app)) as {
@@ -176,8 +204,9 @@ test("H6: with the correlation family mounted, jobKeys populate, stream repoints
176
204
  };
177
205
  assertEquals(res.status, 200);
178
206
  const w = res.body.workers[0];
179
- assertEquals(w.jobKeys, ["6494"], "the correlation registry feeds the jobKeys seam");
180
- assertEquals(w.stream, "job:6494", "the drill stream repoints at the live job's stream");
207
+ assertEquals(w.jobKeys, [], "correlation alone no longer feeds jobKeys (relay demoted)");
208
+ assertEquals(w.stream, "wk-a", "the drill stream stays instance-keyed without a claim");
209
+ // The correlation context is still reported for drill-in.
181
210
  assertEquals(res.body.correlations.length, 1);
182
211
  const c = res.body.correlations[0];
183
212
  assertEquals(c.jobKey, "6494");
@@ -3,11 +3,14 @@
3
3
  // worker list — family, host, current jobs, liveness — grouped by leaf token, sourced from the H1
4
4
  // presence registry (#144). Read-only projection; it NEVER gates control flow (advisory-only, ADR 0056).
5
5
  //
6
- // H6 (#149) closes the loop: the correlation registry (`app/agentic/correlation.ts`) supplies the
7
- // `jobKeysFor` resolver the presence snapshot exposes as a seam, so each worker's current jobKeys light
8
- // up; each worker's drill `stream` is repointed at its jobKey-scoped relay stream (`job:<jobKey>`); and
9
- // the report carries the `correlations` the process-instance / plan context for every current job —
10
- // so the cockpit lines a worker's terminal up with "that process instance / this plan".
6
+ // H6/#713 closes the loop with an EXPLICIT claim registry (`app/agentic/claim-registry.ts`): it is the
7
+ // AUTHORITATIVE source the presence snapshot's `jobKeysFor` seam resolves against, so each worker's
8
+ // current jobKeys light up from `claim` frames not inferred from the relay terminal and appear even
9
+ // with ZERO transcript. Each worker's drill `stream` is repointed at its claimed jobKey-scoped relay
10
+ // stream (`job:<jobKey>`), keyed by the CLAIM (explicit instance+jobKey), not by a connection. The
11
+ // relay correlation registry is DEMOTED to drill-in context only: it still supplies the `correlations`
12
+ // — the process-instance / plan context for a job's terminal — so the cockpit lines a worker's terminal
13
+ // up with "that process instance / this plan", but it is no longer the visibility source.
11
14
  //
12
15
  // This is the supply half of the visibility plane only. The demand×supply matrix, missing-agent-type
13
16
  // reds, and diversity-SLO lights are DE-SCOPED to the enrolment epic #152 — this report carries no
@@ -16,7 +19,8 @@
16
19
  // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): when
17
20
  // NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
18
21
 
19
- import { type CorrelationRegistry, currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
22
+ import { type ClaimRegistry, currentClaimRegistry } from "../app/agentic/claim-registry.ts";
23
+ import { currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
20
24
  import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
21
25
  import { envVar } from "../app/version.ts";
22
26
  import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
@@ -27,13 +31,14 @@ import { defineOperation } from "../nano-generated/operations.ts";
27
31
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
28
32
 
29
33
  // Project a presence-registry row to the wire worker. The drill `stream` defaults to the worker
30
- // instance (H5) but is repointed at the worker's jobKey-scoped relay stream (`job:<jobKey>`) when the
31
- // correlation registry knows a current job for it (H6) — so drilling in opens the LIVE job's terminal.
32
- function toWorker(w: SupplyWorker, correlation: CorrelationRegistry | undefined): AgenticSupplyWorker {
34
+ // instance (H5) but is repointed at the worker's claimed jobKey-scoped relay stream (`job:<jobKey>`)
35
+ // when the claim registry knows a current claim for it (#713) — keyed by the CLAIM, not by the
36
+ // connection so drilling in opens the LIVE job's terminal even before any transcript lands.
37
+ function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSupplyWorker {
33
38
  const out: AgenticSupplyWorker = {
34
39
  instance: w.instance,
35
40
  identity: w.identity,
36
- stream: correlation?.primaryStreamFor(w.instance) ?? w.instance,
41
+ stream: claims?.primaryStreamFor(w.instance) ?? w.instance,
37
42
  jobKeys: [...w.jobKeys],
38
43
  live: w.live,
39
44
  staleMs: w.staleMs,
@@ -67,15 +72,19 @@ export default defineOperation("getAgenticSupply", async ({ req }, app) => {
67
72
  return { status: 200, body: empty };
68
73
  }
69
74
 
70
- // Thread the H6 correlation registry (if mounted) as the presence snapshot's jobKeysFor resolver so a
71
- // worker's current jobKeys populate; absent jobKeys stay empty (advisory, never an error).
75
+ // #713: the CLAIM registry (if mounted) is the authoritative `jobKeysFor` source the presence
76
+ // snapshot resolves against — a worker's current jobKeys come from explicit `claim` frames, not the
77
+ // relay terminal, so they populate with zero transcript. Absent → jobKeys stay empty (advisory).
78
+ const claims = currentClaimRegistry();
79
+ // The correlation registry is demoted to drill-in context only: it still carries the per-job
80
+ // process-instance / plan context surfaced in `correlations`, but no longer feeds visibility.
72
81
  const correlation = currentCorrelation();
73
- const snapshot = registry.snapshot(correlation ? { jobKeysFor: (instance) => correlation.jobKeysFor(instance) } : {});
82
+ const snapshot = registry.snapshot(claims ? { jobKeysFor: (instance) => claims.jobKeysFor(instance) } : {});
74
83
  const report: AgenticSupplyReport = {
75
84
  count: snapshot.count,
76
85
  generatedAt: new Date().toISOString(),
77
- workers: snapshot.workers.map((w) => toWorker(w, correlation)),
78
- leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w, correlation)) })),
86
+ workers: snapshot.workers.map((w) => toWorker(w, claims)),
87
+ leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w, claims)) })),
79
88
  correlations: correlation ? correlation.snapshot().correlations.map(toCorrelation) : [],
80
89
  };
81
90
  return { status: 200, body: report };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.176.1",
3
+ "version": "0.178.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -51,6 +51,8 @@
51
51
  "sync:nav:check": "node --experimental-strip-types scripts/sync-nav.ts --check",
52
52
  "gen:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts",
53
53
  "check:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts --check",
54
+ "sync:mcp-curated": "node --experimental-strip-types scripts/sync-mcp-curated.ts",
55
+ "sync:mcp-curated:check": "node --experimental-strip-types scripts/sync-mcp-curated.ts --check",
54
56
  "gen:cockpit-browser": "node --experimental-strip-types scripts/build-cockpit-browser.ts",
55
57
  "check:cockpit-browser": "node --experimental-strip-types scripts/build-cockpit-browser.ts --check",
56
58
  "dev": "urban dev",
@@ -62,7 +64,7 @@
62
64
  "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
63
65
  },
64
66
  "dependencies": {
65
- "@nanobpm/agentic": "^0.10.0",
67
+ "@nanobpm/agentic": "^0.11.0",
66
68
  "@nanobpm/urban": "^0.90.0",
67
69
  "bpmn-auto-layout": "^2.0.0-alpha.2"
68
70
  },
@@ -106,6 +106,32 @@
106
106
  }
107
107
  }
108
108
  },
109
+ {
110
+ "type": "text",
111
+ "id": "tractable-heading",
112
+ "props": { "text": "Import a curated tool set (tractable surface)", "variant": "heading" }
113
+ },
114
+ {
115
+ "type": "text",
116
+ "id": "tractable-body",
117
+ "props": {
118
+ "text": "The full projected surface is ~56 tools / ~79 KB of tools/list (app operations plus the framework urban_debug_* engine family) \u2014 large enough that a coding-agent harness may defer the whole set behind a tool-search gate, and \"tools\": [\"*\"] imports every one eagerly. Prefer a curated allowlist of the tools you actually drive and read with: set the server entry's \"tools\" to the curated subset instead of [\"*\"]. The curated list is maintained as the single source of truth in the repo (app/mcpToolSurface.ts, CURATED_MCP_TOOLS) and rendered as a copyable JSON block in the runbook \u2014 see docs/mcp-runbook.md \u00a7\"Import a curated subset\". [\"*\"] still works where the client does not defer; the full surface stays reachable either way.",
119
+ "variant": "sub"
120
+ }
121
+ },
122
+ {
123
+ "type": "text",
124
+ "id": "session-recovery-heading",
125
+ "props": { "text": "Recover a lost session (re-initialize on -32000)", "variant": "heading" }
126
+ },
127
+ {
128
+ "type": "text",
129
+ "id": "session-recovery-body",
130
+ "props": {
131
+ "text": "The /app/mcp transport is stateful streamable-HTTP: every tool call carries an mcp-session-id, and a call with a missing / stale / idle-dropped / proxy-reset / evicted session id is refused with -32000 \"no valid session id, and not an initialize request.\" A client that does not re-handshake then sees the whole surface report \"tool does not exist\" until it re-initializes \u2014 a single hiccup (notably a heavy-tool timeout) can brick every tool. The self-heal is a fresh initialize handshake, which mints a new session and restores the entire catalogue in one round trip; a well-behaved MCP client does this automatically on a -32000. A stateless/resumable transport that removes the session dependency is tracked upstream (nano-ide#488).",
132
+ "variant": "sub"
133
+ }
134
+ },
109
135
  {
110
136
  "type": "text",
111
137
  "id": "secret-heading",
@@ -0,0 +1,19 @@
1
+ // Drift guard for the curated MCP tool block in the runbook (issue #715).
2
+ //
3
+ // The curated `"tools"` allowlist is authored once in `app/mcpToolSurface.ts` and rendered into
4
+ // `docs/mcp-runbook.md` by `scripts/sync-mcp-curated.ts`. This test — run under `npm test`, which CI
5
+ // already gates — fails if the runbook block drifts from the source of truth, so the enforcement does
6
+ // not depend on a separate workflow step (AGENTS.md: "Derivation over duplication: no drift
7
+ // surfaces"). Run `npm run sync:mcp-curated` to reconcile. Mirrors `scripts/sync-nav.test.ts`.
8
+ import { test } from "node:test";
9
+ import { assert } from "#test-assert";
10
+ import { reconciledRunbook } from "./sync-mcp-curated.ts";
11
+
12
+ test("docs/mcp-runbook.md curated-tools block is in sync with app/mcpToolSurface.ts", () => {
13
+ const { current, next } = reconciledRunbook();
14
+ assert(
15
+ current === next,
16
+ "docs/mcp-runbook.md curated-tools block is STALE vs app/mcpToolSurface.ts — " +
17
+ "run `npm run sync:mcp-curated` and commit the result.",
18
+ );
19
+ });
@@ -0,0 +1,77 @@
1
+ // Render the curated MCP tool allowlist (`CURATED_MCP_TOOLS`, app/mcpToolSurface.ts) into the runbook
2
+ // (docs/mcp-runbook.md) between its `curated-tools` sentinels (issue #715).
3
+ //
4
+ // The curated `"tools"` allowlist an MCP client imports instead of `["*"]` (the tractable-surface
5
+ // subset) is authored ONCE in `app/mcpToolSurface.ts`. The runbook shows it as a copyable JSON block;
6
+ // this script derives that block from the source so the two can never drift (AGENTS.md "no drift
7
+ // surfaces"), mirroring the repo's other derive/verify pairs (sync-nav, inline-mcp-bodies, layout-bpmn).
8
+ //
9
+ // node --experimental-strip-types scripts/sync-mcp-curated.ts # write the runbook block
10
+ // node --experimental-strip-types scripts/sync-mcp-curated.ts --check # verify (CI) — non-zero on drift
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
+ import process from "node:process";
13
+ import { pathToFileURL } from "node:url";
14
+ import { CURATED_MCP_TOOLS } from "../app/mcpToolSurface.ts";
15
+
16
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
17
+ export const RUNBOOK_PATH = `${ROOT}docs/mcp-runbook.md`;
18
+
19
+ const BEGIN = "<!-- BEGIN GENERATED: curated-tools (npm run sync:mcp-curated) -->";
20
+ const END = "<!-- END GENERATED: curated-tools -->";
21
+
22
+ /** The generated block: a fenced JSON array of curated tool names, one per line, in authored order. */
23
+ export function renderBlock(): string {
24
+ const lines = CURATED_MCP_TOOLS.map((name, i) => {
25
+ const comma = i === CURATED_MCP_TOOLS.length - 1 ? "" : ",";
26
+ return ` ${JSON.stringify(name)}${comma}`;
27
+ });
28
+ return ["```json", "[", ...lines, "]", "```"].join("\n");
29
+ }
30
+
31
+ export function replaceBetweenSentinels(source: string, block: string): string {
32
+ const begin = source.indexOf(BEGIN);
33
+ const end = source.indexOf(END);
34
+ if (begin === -1 || end === -1 || end < begin) {
35
+ throw new Error(
36
+ `docs/mcp-runbook.md is missing the curated-tools sentinels (${BEGIN} … ${END}). ` +
37
+ "Restore them so the generated block has a home.",
38
+ );
39
+ }
40
+ const before = source.slice(0, begin + BEGIN.length);
41
+ const after = source.slice(end);
42
+ return `${before}\n${block}\n${after}`;
43
+ }
44
+
45
+ /** The runbook content the generator WOULD write given its current on-disk state. */
46
+ export function reconciledRunbook(): { current: string; next: string } {
47
+ const current = readFileSync(RUNBOOK_PATH, "utf8");
48
+ return { current, next: replaceBetweenSentinels(current, renderBlock()) };
49
+ }
50
+
51
+ function main(): void {
52
+ const check = process.argv.includes("--check");
53
+ const { current, next } = reconciledRunbook();
54
+
55
+ if (check) {
56
+ if (current !== next) {
57
+ console.error(
58
+ "docs/mcp-runbook.md curated-tools block is STALE vs app/mcpToolSurface.ts. " +
59
+ "Run `npm run sync:mcp-curated` and commit the result.",
60
+ );
61
+ process.exit(1);
62
+ }
63
+ console.log("sync:mcp-curated: runbook curated-tools block is up to date.");
64
+ return;
65
+ }
66
+ if (current !== next) {
67
+ writeFileSync(RUNBOOK_PATH, next);
68
+ console.log("sync:mcp-curated: wrote curated-tools block into docs/mcp-runbook.md.");
69
+ } else {
70
+ console.log("sync:mcp-curated: runbook curated-tools block already up to date.");
71
+ }
72
+ }
73
+
74
+ // Run as a CLI only when invoked directly (not when imported by the drift test).
75
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
76
+ main();
77
+ }
@@ -154,6 +154,7 @@ test("E2E: the whole visibility plane wires up — presence, correlation, supply
154
154
  assert(names.includes("presence"), "H1 presence family is discovered by the H0 seam");
155
155
  assert(names.includes("relay"), "H3 relay family is discovered by the H0 seam");
156
156
  assert(names.includes("correlation"), "H6 correlation family is discovered by the H0 seam");
157
+ assert(names.includes("claim"), "#713 claim family is discovered by the H0 seam");
157
158
 
158
159
  // ── H1: a worker connects and REGISTERs; a live presence row appears with its family/host. ──
159
160
  const worker = conn("wk-conn-1", "leafA");
@@ -162,7 +163,14 @@ test("E2E: the whole visibility plane wires up — presence, correlation, supply
162
163
  worker.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: { family: "opus", host: "boxA" } } });
163
164
  await flush();
164
165
 
165
- // ── H6: the orchestrator links the worker's active jobKey to its process instance / plan. ──
166
+ // ── #713: the worker CLAIMs its active jobKey the authoritative visibility source (explicit
167
+ // instance, no relay/connection inference). This is what lights up jobKeys, even before any
168
+ // transcript lands. ──
169
+ worker.feed({ lane: "control", family: "claim", seq: 2, payload: { instance: "wk-a", jobKey: JOB } });
170
+ await flush();
171
+
172
+ // ── H6: the orchestrator links the worker's active jobKey to its process instance / plan (drill-in
173
+ // context only, since #713 — no longer the visibility source). ──
166
174
  const correlation = currentCorrelation();
167
175
  assert(correlation !== undefined, "the correlation family installed the singleton");
168
176
  correlation.link("wk-a", JOB, { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "nanobpm/nano-workforce#142" });
@@ -179,8 +187,8 @@ test("E2E: the whole visibility plane wires up — presence, correlation, supply
179
187
  assertEquals(w.instance, "wk-a");
180
188
  assertEquals(w.family, "opus");
181
189
  assertEquals(w.host, "boxA");
182
- assertEquals(w.jobKeys, [JOB], "H1×H6: the correlation registry feeds the presence jobKeys seam");
183
- assertEquals(w.stream, STREAM, "H6: the drill stream repoints at the live job's stream");
190
+ assertEquals(w.jobKeys, [JOB], "#713: the claim registry feeds the presence jobKeys seam");
191
+ assertEquals(w.stream, STREAM, "#713: the drill stream repoints at the claimed job's stream");
184
192
  assertEquals(res.body.correlations.length, 1);
185
193
  const c = res.body.correlations[0];
186
194
  assertEquals(c.jobKey, JOB);