ast-mcp-server 0.13.0 → 0.13.1

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/CHANGELOG.md CHANGED
@@ -6,7 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.13.0] — local release candidate, pending release (2026-08-27)
9
+ ## [0.13.1] - 2026-09-01
10
+
11
+ ### Fixed
12
+
13
+ - The DeepSeek Harness adapter now opts into a bounded canonical-JSON text projection when a successful MCP result has structured data but no ordinary text, preserving lossless `structuredContent` while making native Agent/Session results useful and durable. The mandatory pinned-host smoke freezes the public 0.13.0 RED baseline and verifies the corrected candidate across the next model request, persisted `tool/result`, cold replay, guarded catalog, and cleanup readback.
14
+ - `ast_explore` now publishes its complete object input schema while retaining all cross-field runtime rejection, and `ast_get_impact` no longer advertises one false output shape across JSON and TOON. The pinned Harness gate binds the public empty-schema RED to candidate registry/native parity and unchanged hashes for the other 14 model schemas.
15
+ - Timeout ownership is now explicit across the Harness, queue, and AST worker: the shipped outer timeout exceeds the complete queue/execution/margin budget, deterministic slow work returns bounded AST-owned operational errors, queued work never starts after its deadline, cancellation remains correlated across worker generations, and cleanup proves zero active work, held requests, listeners, or owned processes.
16
+ - Harness lifecycle handling now converges deterministically through MCP removal and reconnect, rejects retired-generation effects, preserves AST `REQUEST_CANCELLED` authority separately from bridge transport observations, and completes correlated shutdown without residual state. The pinned native and rendered-GUI gates verify the `15→0→15` catalog sequence, request/header identity, secret-safe diagnostics, and ordered cleanup that still runs every owner check after an earlier cleanup failure.
17
+
18
+ ### Release status
19
+
20
+ - This is the reviewed `0.13.1` release candidate. Publication to npm `next`, public-registry verification, promotion to `latest`, tagging, and GitHub Release creation remain pending and are not claimed here.
21
+
22
+ ## [0.13.0] — published to `latest` and `next`, registry verified (2026-08-29)
10
23
 
11
24
  ### Added
12
25
 
package/README.md CHANGED
@@ -329,30 +329,47 @@ A thin adapter ships inside this package: `cordis.patch.yml` mounts the packaged
329
329
  [`@deepseek-ai/dsh-mcp-client`](https://github.com/deepseek-ai/deepseek-harness/blob/cd5ef8148158c3a752a658978873241fdf8e2bbc/packages/mcp/mcp-client/README.md)
330
330
  bridge, declared through exactly `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`.
331
331
  This is a Developer Preview against a pinned, **source-built** Harness revision
332
- (`dsh-v0.1.2-alpha.1` at `cd5ef8148158c3a752a658978873241fdf8e2bbc`); that revision
333
- is validated from source, and `ast-mcp-server@0.13.0` is not published yet. Install a
334
- locally packed tarball instead:
332
+ (`dsh-v0.1.2-alpha.1` at `cd5ef8148158c3a752a658978873241fdf8e2bbc`). The published
333
+ `ast-mcp-server@0.13.0` package is the compatibility baseline:
334
+
335
+ ```bash
336
+ dsh plugin --profile web add ast-mcp-server@0.13.0
337
+ ```
338
+
339
+ For a local candidate, pack and install its tarball instead:
335
340
 
336
341
  ```bash
337
342
  yarn pack --out ast-mcp-server-%v.tgz
338
- dsh plugin --profile web add ./ast-mcp-server-0.13.0.tgz
343
+ dsh plugin --profile web add ./ast-mcp-server-0.13.1.tgz
339
344
  ```
340
345
 
341
346
  The first supported surface is **reads + prepare + preview**. Every apply path is
342
347
  denied by a fail-closed guard: `ast_apply_operation` is not registered unless
343
348
  `AST_MCP_APPLY_GUARD=allow` is set explicitly (the shipped patch instead pins `deny`,
344
- so the Harness surface stays deny-by-default; an unset or invalid value also denies). Known upstream
345
- gaps apply and are never safety authority: the official bridge drops MCP tool
346
- annotations (`readOnlyHint`/`destructiveHint`) and launches the stdio child outside
347
- the Harness sandbox.
348
-
349
- `yarn test:dsh-adapter` is the mandatory verification: it packs the exact tarball,
350
- installs it into an isolated profile on the pinned Harness, proves `--dump-config`
351
- composition including effective `tools.mode: native`, and — through the Harness registry
352
- itself — invokes the server-qualified read, dry-run rename prepare, and preview tools.
353
- It separately proves `mcp__ast__ast_apply_operation` is absent and that a bounded direct
354
- registry invocation is rejected. The smoke fails (never skips) if the pinned Harness
355
- revision, tag, CLI/client version, clean source identity, or a prerequisite is missing.
349
+ so the Harness surface stays deny-by-default; an unset or invalid value also denies).
350
+ The adapter also sets `AST_MCP_TEXT_PROJECTION=canonical_json`: successful structured
351
+ results keep their lossless `structuredContent` and gain canonical JSON text only when
352
+ ordinary MCP text is empty, because the pinned native presenter otherwise exposes only a
353
+ non-useful empty-result marker. The projection is adapter-specific, never replaces existing
354
+ text, and reports an explicit size-limit marker when the complete supervised frame has room.
355
+ If even the unchanged structured-only result exceeds the existing worker frame, supervised mode
356
+ fails closed rather than truncating or corrupting it. Known upstream gaps remain non-authoritative:
357
+ the official bridge drops MCP tool annotations
358
+ (`readOnlyHint`/`destructiveHint`) and launches the stdio child outside the Harness sandbox.
359
+
360
+ `yarn test:dsh-adapter` is the mandatory verification. It binds the public 0.13.0 npm
361
+ integrity, packs the candidate, builds the pinned Harness and bridge from source, and proves
362
+ `tools.mode: native`, the 15-tool scoped catalog, complete executable input schemas,
363
+ read/prepare/preview, all three invalid `ast_explore` combinations, apply absence, and rejected direct apply.
364
+ The schema gate freezes the public empty `ast_explore` contract as RED, requires the candidate
365
+ registry/native definitions to match, and hashes all 14 unaffected model schemas. A deterministic
366
+ two-step model then invokes
367
+ `mcp__ast__ast_get_project_status` through a real headless Agent/Session: the public baseline
368
+ must reproduce the empty-result marker, while the corrected candidate must deliver lossless
369
+ canonical JSON to the next model request, the durable `tool/result`, and a cold Agent
370
+ resume/replay reconstructed from persistence. The smoke removes and read-backs its disposable
371
+ profile/workspace state and fails
372
+ (never skips) on an identity mismatch, missing prerequisite, lifecycle leak, or evidence gap.
356
373
 
357
374
  ## MCP tools
358
375
 
package/cordis.patch.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  # Mounts the packaged `ast-mcp-server` stdio command through the official
4
4
  # `@deepseek-ai/dsh-mcp-client` bridge. Harness does not resolve MCP binaries
5
5
  # from PATH, so the entrypoint is resolved relative to the profile directory
6
- # (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.13.0.tgz`
6
+ # (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.13.1.tgz`
7
7
  # installs a locally packed package as a dependency.
8
8
  #
9
9
  # First-slice surface: reads + prepare + preview. Every apply path is denied
@@ -13,6 +13,8 @@
13
13
  # - the official bridge drops MCP tool.annotations, incl. readOnlyHint and
14
14
  # destructiveHint, so the guard enumerates tool names instead of hints;
15
15
  # - the stdio child runs outside the Harness sandbox.
16
+ # Harness native presentation reads text content, so this adapter explicitly
17
+ # requests a lossless canonical JSON projection while preserving structuredContent.
16
18
  - insert:
17
19
  - id: mcp-ast
18
20
  name: "@deepseek-ai/dsh-mcp-client"
@@ -27,5 +29,10 @@
27
29
  )
28
30
  env:
29
31
  AST_MCP_APPLY_GUARD: "deny"
32
+ AST_MCP_TEXT_PROJECTION: "canonical_json"
30
33
  cwd: !!js process.cwd()
34
+ toolCallTimeoutMs: !!js >-
35
+ JSON.parse(process.getBuiltinModule('node:fs').readFileSync(
36
+ new URL('node_modules/ast-mcp-server/package.json', baseUrl), 'utf8'
37
+ )).deepseekHarness.timeoutBudget.outerToolCallMs
31
38
  failOnStartupError: true
package/dist/server.js CHANGED
@@ -2,9 +2,20 @@ import { createRequire } from "node:module";
2
2
  import { McpServer, } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { PublicOperationalError } from "./services/public-errors.js";
4
4
  import { toolCatalog } from "./tools/catalog.js";
5
- import { createToolErrorContext, errorResult } from "./tools/result.js";
5
+ import { createToolErrorContext, errorResult, projectStructuredContentAsText, } from "./tools/result.js";
6
6
  const packageMetadata = createRequire(import.meta.url)("../package.json");
7
7
  export const PACKAGE_VERSION = packageMetadata.version;
8
+ function installStructuredContentTextProjection(server) {
9
+ const registerTool = server.registerTool.bind(server);
10
+ function projectedRegisterTool(name, config, callback) {
11
+ const projectedCallback = (async (...args) => {
12
+ const result = await Reflect.apply(callback, undefined, args);
13
+ return projectStructuredContentAsText(result, true);
14
+ });
15
+ return registerTool(name, config, projectedCallback);
16
+ }
17
+ server.registerTool = projectedRegisterTool;
18
+ }
8
19
  function installRuntimeActivityTracking(server, runtimeActivity) {
9
20
  const registerTool = server.registerTool.bind(server);
10
21
  function trackedRegisterTool(name, config, callback) {
@@ -25,6 +36,9 @@ export function createServer(options = {}) {
25
36
  });
26
37
  if (options.runtimeActivity)
27
38
  installRuntimeActivityTracking(server, options.runtimeActivity);
39
+ if (options.projectStructuredContentAsText === true) {
40
+ installStructuredContentTextProjection(server);
41
+ }
28
42
  if (options.denyApply === undefined) {
29
43
  toolCatalog.registerAll(server);
30
44
  }
@@ -4,8 +4,9 @@ import { randomUUID } from "node:crypto";
4
4
  import { createInterface } from "node:readline";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
6
  import { fileURLToPath } from "node:url";
7
- import { decodeCompilerWorkerEnvelope, startCompilerWorker } from "./compiler-worker-protocol.js";
8
- import { createCompilerWorkerSpawnSpec, readRuntimePolicy, } from "./runtime-policy.js";
7
+ import { COMPILER_WORKER_MAX_FRAME_BYTES, decodeCompilerWorkerEnvelope, decodeCompilerWorkerResponse, isCompilerWorkerRequestIdWithinBudget, startCompilerWorker, } from "./compiler-worker-protocol.js";
8
+ import { emitConfiguredH03HostEvent } from "./h03-timeout-fixture.js";
9
+ import { createCompilerWorkerSpawnSpec, parseH03FixtureDescriptor, parseH05FixtureDescriptor, readRuntimePolicy, } from "./runtime-policy.js";
9
10
  import { emitCompilerWorkerEvent } from "./runtime-logger.js";
10
11
  import { PublicOperationalError, renderPublicError } from "./public-errors.js";
11
12
  export class CompilerWorkerHostError extends Error {
@@ -17,7 +18,7 @@ export class CompilerWorkerHostError extends Error {
17
18
  this.recovery = recovery;
18
19
  }
19
20
  }
20
- const bounded = (value) => Buffer.byteLength(JSON.stringify(value)) <= 256 * 1024;
21
+ const bounded = (value) => Buffer.byteLength(JSON.stringify(value)) <= COMPILER_WORKER_MAX_FRAME_BYTES;
21
22
  const within = (promise, ms) => Promise.race([
22
23
  promise,
23
24
  delay(ms, undefined, { ref: false }).then(() => {
@@ -34,7 +35,11 @@ function deferred() {
34
35
  return { promise, resolve, reject };
35
36
  }
36
37
  export function spawnCompilerWorkerProcess(options) {
37
- const spec = createCompilerWorkerSpawnSpec(options.workerEntryPath, options.environment);
38
+ const fixture = parseH03FixtureDescriptor(options.environment.AST_H03_FIXTURE);
39
+ const lifecycleFixture = parseH05FixtureDescriptor(options.environment.AST_H05_FIXTURE);
40
+ // prettier-ignore
41
+ const environment = fixture || lifecycleFixture ? { ...options.environment, ...(fixture ? { AST_H03_FIXTURE: JSON.stringify({ ...fixture, generation: options.generation }) } : {}), ...(lifecycleFixture ? { AST_H05_FIXTURE: JSON.stringify({ ...lifecycleFixture, generation: options.generation }) } : {}) } : options.environment;
42
+ const spec = createCompilerWorkerSpawnSpec(options.workerEntryPath, environment);
38
43
  if (!spec.ok || !spec.command)
39
44
  throw new CompilerWorkerHostError("startup");
40
45
  const child = spawn(spec.command, spec.args, {
@@ -86,17 +91,12 @@ export function spawnCompilerWorkerProcess(options) {
86
91
  snapshots.shift()?.resolve(decoded.value);
87
92
  });
88
93
  createInterface({ input: child.stdout }).on("line", (line) => {
89
- if (Buffer.byteLength(line) > 256 * 1024)
94
+ const decoded = decodeCompilerWorkerResponse(line);
95
+ if (!decoded.ok)
90
96
  return fail(new CompilerWorkerHostError("protocol"));
91
- let message;
92
- try {
93
- message = JSON.parse(line);
94
- }
95
- catch {
96
- return fail(new CompilerWorkerHostError("protocol"));
97
- }
98
- if (!("id" in message) || message.id === undefined)
97
+ if (decoded.kind === "notification")
99
98
  return;
99
+ const message = decoded.value;
100
100
  const request = pending.get(message.id);
101
101
  if (!request)
102
102
  return;
@@ -108,7 +108,10 @@ export function spawnCompilerWorkerProcess(options) {
108
108
  });
109
109
  });
110
110
  const write = (message, acknowledge = false) => {
111
- if (!bounded(message) || !child.stdin?.writable)
111
+ const requestIdWithinBudget = !("id" in message) ||
112
+ message.id === undefined ||
113
+ isCompilerWorkerRequestIdWithinBudget(message.id);
114
+ if (!requestIdWithinBudget || !bounded(message) || !child.stdin?.writable)
112
115
  return Promise.reject(new CompilerWorkerHostError("protocol"));
113
116
  options.beforeWrite?.(message);
114
117
  const writeSequence = ++writes, processed = acknowledge ? deferred() : undefined;
@@ -217,7 +220,7 @@ export class CompilerWorkerHost {
217
220
  return this.scheduleIdle(); this.parentAdmission = "closed"; const second = await child.snapshot(); if (child !== this.child || generation !== this.generation || sequence !== this.sequence || activity !== this.activity || !CompilerWorkerHost.quiescent(second)) {
218
221
  this.parentAdmission = "open";
219
222
  return this.scheduleIdle();
220
- } await this.stopChild(child, "requested"); emitCompilerWorkerEvent({ kind: "idle", generation }); this.parentAdmission = "open"; }
223
+ } await this.stopChild(child, "requested"); emitCompilerWorkerEvent({ kind: "idle", generation }); emitConfiguredH03HostEvent("recycled", generation); this.parentAdmission = "open"; }
221
224
  // prettier-ignore
222
225
  async stopChild(child, trigger) { const completion = child.shutdown(trigger); try {
223
226
  await within(completion, this.timeoutMs);
@@ -412,7 +415,24 @@ export async function runSupervisedStdioServer() {
412
415
  const rendered = ambiguous
413
416
  ? renderPublicError(new PublicOperationalError("AMBIGUOUS_APPLY", "The apply outcome is ambiguous."), ambiguous.correlation_id).envelope.error
414
417
  : undefined;
415
- process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32603, message: rendered?.message ?? "Compiler worker request failed.", ...(ambiguous ? { data: { code: "AMBIGUOUS_APPLY", ...ambiguous } } : {}) } })}\n`);
418
+ const responseId = isCompilerWorkerRequestIdWithinBudget(message.id) ? message.id : null;
419
+ const candidate = {
420
+ jsonrpc: "2.0",
421
+ id: responseId,
422
+ error: {
423
+ code: -32603,
424
+ message: rendered?.message ?? "Compiler worker request failed.",
425
+ ...(ambiguous ? { data: { code: "AMBIGUOUS_APPLY", ...ambiguous } } : {}),
426
+ },
427
+ };
428
+ const response = bounded(candidate)
429
+ ? candidate
430
+ : {
431
+ jsonrpc: "2.0",
432
+ id: null,
433
+ error: { code: -32603, message: "Compiler worker request failed." },
434
+ };
435
+ process.stdout.write(`${JSON.stringify(response)}\n`);
416
436
  }
417
437
  }
418
438
  };
@@ -1,11 +1,79 @@
1
1
  import { Buffer } from "node:buffer";
2
+ export const COMPILER_WORKER_MAX_FRAME_BYTES = 256 * 1024;
3
+ export const COMPILER_WORKER_MAX_REQUEST_ID_BYTES = 8 * 1024;
4
+ export const COMPILER_WORKER_JSONRPC_ENVELOPE_RESERVE_BYTES = 16 * 1024;
5
+ export const COMPILER_WORKER_MAX_RESULT_BYTES = COMPILER_WORKER_MAX_FRAME_BYTES - COMPILER_WORKER_JSONRPC_ENVELOPE_RESERVE_BYTES;
2
6
  const FIELDS = new Set(["v", "generation", "sequence", "type", "payload"]);
3
7
  const TYPES = new Set(["handshake", "snapshot", "shutdown", "ready", "settled", "lease", "exit"]);
4
8
  function isRecord(value) {
5
9
  return typeof value === "object" && value !== null && !Array.isArray(value);
6
10
  }
11
+ export function isCompilerWorkerRequestIdWithinBudget(id) {
12
+ try {
13
+ return Buffer.byteLength(JSON.stringify(id)) <= COMPILER_WORKER_MAX_REQUEST_ID_BYTES;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ export function fitsCompilerWorkerResponseResult(result) {
20
+ try {
21
+ const frame = JSON.stringify({
22
+ jsonrpc: "2.0",
23
+ id: "x".repeat(COMPILER_WORKER_MAX_REQUEST_ID_BYTES - 2),
24
+ result,
25
+ });
26
+ return Buffer.byteLength(frame) <= COMPILER_WORKER_MAX_FRAME_BYTES;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export function decodeCompilerWorkerResponse(frame) {
33
+ if (Buffer.byteLength(frame) > COMPILER_WORKER_MAX_FRAME_BYTES) {
34
+ return { ok: false, reason: "oversized" };
35
+ }
36
+ let value;
37
+ try {
38
+ value = JSON.parse(frame);
39
+ }
40
+ catch {
41
+ return { ok: false, reason: "malformed" };
42
+ }
43
+ if (!isRecord(value) || value.jsonrpc !== "2.0") {
44
+ return { ok: false, reason: "invalid_response" };
45
+ }
46
+ if (Object.hasOwn(value, "method") && !Object.hasOwn(value, "id")) {
47
+ if (typeof value.method !== "string" ||
48
+ Object.keys(value).some((key) => !["jsonrpc", "method", "params"].includes(key))) {
49
+ return { ok: false, reason: "invalid_notification" };
50
+ }
51
+ return { ok: true, kind: "notification", value };
52
+ }
53
+ if (Object.keys(value).some((key) => !["jsonrpc", "id", "result", "error"].includes(key))) {
54
+ return { ok: false, reason: "unknown_field" };
55
+ }
56
+ if (!(typeof value.id === "string" || typeof value.id === "number") ||
57
+ !isCompilerWorkerRequestIdWithinBudget(value.id)) {
58
+ return { ok: false, reason: "invalid_id" };
59
+ }
60
+ const hasResult = Object.hasOwn(value, "result");
61
+ const hasError = Object.hasOwn(value, "error");
62
+ if (hasResult === hasError)
63
+ return { ok: false, reason: "invalid_response" };
64
+ if (hasError) {
65
+ const error = value.error;
66
+ if (!isRecord(error) ||
67
+ typeof error.code !== "number" ||
68
+ !Number.isSafeInteger(error.code) ||
69
+ typeof error.message !== "string") {
70
+ return { ok: false, reason: "invalid_error" };
71
+ }
72
+ }
73
+ return { ok: true, kind: "response", value };
74
+ }
7
75
  export function decodeCompilerWorkerEnvelope(frame) {
8
- if (Buffer.byteLength(frame) > 256 * 1024) {
76
+ if (Buffer.byteLength(frame) > COMPILER_WORKER_MAX_FRAME_BYTES) {
9
77
  return { ok: false, reason: "oversized" };
10
78
  }
11
79
  let value;
@@ -47,7 +115,7 @@ export function validateInitializationReplay(frames) {
47
115
  "id" in notification) {
48
116
  return { ok: false, reason: "missing_replay" };
49
117
  }
50
- if (Buffer.byteLength(JSON.stringify(frames)) > 256 * 1024) {
118
+ if (Buffer.byteLength(JSON.stringify(frames)) > COMPILER_WORKER_MAX_FRAME_BYTES) {
51
119
  return { ok: false, reason: "oversized_replay" };
52
120
  }
53
121
  return { ok: true, frames: [request, notification] };
@@ -0,0 +1,335 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { appendFileSync, readFileSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { PUBLIC_ERROR_CODES, sanitizePublicText } from "./public-errors.js";
5
+ import { parseH03FixtureDescriptor, parseH05FixtureDescriptor, } from "./runtime-policy.js";
6
+ const FIXTURE_ID = /^[A-Za-z0-9_-]{1,64}$/;
7
+ export class H03TimeoutFixtureError extends Error {
8
+ name = "H03TimeoutFixtureError";
9
+ code = "STALE_GENERATION";
10
+ }
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12
+ /** Closed test-only lifecycle controller used by H-05 through ast_get_project_status. */
13
+ export class H05LifecycleFixtureController {
14
+ #descriptor;
15
+ #generationAbort = new AbortController();
16
+ #gates = new Map();
17
+ #events = [];
18
+ #active = 0;
19
+ #abortListeners = 0;
20
+ #staleSettlements = 0;
21
+ // prettier-ignore
22
+ constructor(descriptor) { if (!statSync(descriptor.controlDirectory, { throwIfNoEntry: false })?.isDirectory())
23
+ throw new Error("H-05 fixture control path must be an existing directory."); this.#descriptor = Object.freeze({ ...descriptor }); }
24
+ // prettier-ignore
25
+ capture(command) { if (Object.keys(command).sort().join(",") !== "callId,correlationId,fixtureId" || !FIXTURE_ID.test(command.callId) || !FIXTURE_ID.test(command.fixtureId) || !UUID.test(command.correlationId))
26
+ throw new Error("Invalid H-05 fixture command."); return Object.freeze({ ...command, generation: this.#descriptor.generation, ownerToken: this.#descriptor.ownerToken }); }
27
+ // prettier-ignore
28
+ hold(identity) { this.#assertCurrent(identity); const key = this.#key(identity); if (this.#gates.has(key))
29
+ throw new Error("Invalid or duplicate H-05 fixture id."); let release; const promise = new Promise((resolve) => (release = resolve)); this.#gates.set(key, { promise, release }); }
30
+ // prettier-ignore
31
+ advance(descriptor) { if (descriptor.generation <= this.#descriptor.generation)
32
+ throw new Error("H-05 fixture generation must increase."); this.#descriptor = Object.freeze({ ...descriptor }); this.#generationAbort.abort(); this.#generationAbort = new AbortController(); for (const gate of this.#gates.values())
33
+ gate.release(); this.#gates.clear(); }
34
+ // prettier-ignore
35
+ async run(identity, context, operation) {
36
+ this.#assertIdentity(identity);
37
+ const eventIdentity = { callId: identity.callId, correlationId: identity.correlationId, fixtureId: identity.fixtureId, generation: identity.generation, ownerToken: identity.ownerToken };
38
+ const generationSignal = this.#generationAbort.signal, signal = AbortSignal.any([context.signal, generationSignal]);
39
+ const checkpoint = () => { if (!this.accepts(identity) || generationSignal.aborted)
40
+ throw new H03TimeoutFixtureError(); context.checkpoint(); };
41
+ const hooked = Object.freeze({ get sequence() { return context.sequence; }, get phase() { return context.phase; }, signal, checkpoint, markExecuting: () => context.markExecuting(), enterCompletionCritical: () => { checkpoint(); context.enterCompletionCritical(); } });
42
+ this.#active += 1;
43
+ this.#emit({ ...eventIdentity, phase: "started" });
44
+ try {
45
+ const gate = this.#gates.get(this.#key(identity));
46
+ if (gate)
47
+ await this.#awaitGate(identity, gate, hooked);
48
+ checkpoint();
49
+ const result = await operation(hooked);
50
+ checkpoint();
51
+ this.#emit({ ...eventIdentity, phase: "terminal", outcome: "succeeded" });
52
+ return result;
53
+ }
54
+ catch (error) {
55
+ const code = error?.code, stale = !this.accepts(identity) || generationSignal.aborted, deadline = code === "OPERATION_DEADLINE_EXCEEDED", cancelled = code === "REQUEST_CANCELLED" || context.signal.aborted;
56
+ if (stale)
57
+ this.#staleSettlements += 1;
58
+ this.#emit({ ...eventIdentity, phase: "terminal", outcome: stale ? "STALE_GENERATION" : deadline ? code : cancelled ? "REQUEST_CANCELLED" : "failed" });
59
+ if (stale)
60
+ this.#staleSettlements -= 1;
61
+ throw stale ? new H03TimeoutFixtureError() : error;
62
+ }
63
+ finally {
64
+ this.#active -= 1;
65
+ }
66
+ }
67
+ // prettier-ignore
68
+ drainEvents() { const events = Object.freeze(this.#events); this.#events = []; return events; }
69
+ // prettier-ignore
70
+ snapshot() { return Object.freeze({ active: this.#active, held: this.#gates.size, abortListeners: this.#abortListeners, timers: 0, staleSettlements: this.#staleSettlements }); }
71
+ // prettier-ignore
72
+ async #awaitGate(identity, gate, context) { let rejectAbort; const aborted = new Promise((_, reject) => (rejectAbort = reject)); const abort = () => { this.#gates.delete(this.#key(identity)); try {
73
+ context.checkpoint();
74
+ }
75
+ catch (error) {
76
+ rejectAbort(error);
77
+ } }; context.signal.addEventListener("abort", abort, { once: true }); this.#abortListeners += 1; try {
78
+ if (context.signal.aborted)
79
+ abort();
80
+ await Promise.race([gate.promise, aborted]);
81
+ }
82
+ finally {
83
+ context.signal.removeEventListener("abort", abort);
84
+ this.#abortListeners -= 1;
85
+ } }
86
+ // prettier-ignore
87
+ #emit(event) { const normalized = Object.freeze(Object.fromEntries(Object.entries(event).filter(([, value]) => value !== undefined))); this.#events.push(normalized); appendFileSync(path.join(this.#descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify(normalized)}\n`, { encoding: "utf8", flag: "a" }); }
88
+ // prettier-ignore
89
+ accepts(identity) { return identity.generation === this.#descriptor.generation && identity.ownerToken === this.#descriptor.ownerToken; }
90
+ // prettier-ignore
91
+ #assertCurrent(identity) { this.#assertIdentity(identity); if (!this.accepts(identity))
92
+ throw new H03TimeoutFixtureError(); }
93
+ // prettier-ignore
94
+ #assertIdentity(identity) { if (!Object.isFrozen(identity) || !FIXTURE_ID.test(identity.callId) || !FIXTURE_ID.test(identity.fixtureId) || !UUID.test(identity.correlationId))
95
+ throw new Error("Invalid H-05 lifecycle identity."); }
96
+ // prettier-ignore
97
+ #key(identity) { return `${identity.generation}:${identity.fixtureId}`; }
98
+ }
99
+ export class H03TimeoutFixtureController {
100
+ #descriptor;
101
+ #gates = new Map();
102
+ #events = [];
103
+ #generation;
104
+ #generationAbort = new AbortController();
105
+ #active = 0;
106
+ #abortListeners = 0;
107
+ #staleSettlements = 0;
108
+ constructor(descriptor) {
109
+ const directory = statSync(descriptor.controlDirectory, { throwIfNoEntry: false });
110
+ if (!directory?.isDirectory())
111
+ throw new Error("H-03 fixture control path must be an existing directory.");
112
+ this.#descriptor = descriptor;
113
+ this.#generation = descriptor.generation;
114
+ }
115
+ hold(fixtureId) {
116
+ this.#assertId(fixtureId);
117
+ const key = this.#key(fixtureId, this.#generation);
118
+ if (this.#gates.has(key))
119
+ throw new Error("Invalid or duplicate H-03 fixture id.");
120
+ let release;
121
+ const promise = new Promise((resolve) => (release = resolve));
122
+ this.#gates.set(key, { promise, release });
123
+ }
124
+ release(fixtureId, generation) {
125
+ if (!FIXTURE_ID.test(fixtureId) || generation !== this.#generation)
126
+ return false;
127
+ const key = this.#key(fixtureId, generation);
128
+ const gate = this.#gates.get(key);
129
+ if (!gate)
130
+ return false;
131
+ this.#gates.delete(key);
132
+ gate.release();
133
+ return true;
134
+ }
135
+ advanceGeneration(generation) {
136
+ if (!Number.isSafeInteger(generation) || generation <= this.#generation)
137
+ throw new Error("H-03 fixture generation must increase.");
138
+ this.#generation = generation;
139
+ this.#generationAbort.abort();
140
+ this.#generationAbort = new AbortController();
141
+ for (const gate of this.#gates.values())
142
+ gate.release();
143
+ this.#gates.clear();
144
+ }
145
+ async run(fixtureId, generation, context, operation, callId = fixtureId) {
146
+ this.#assertId(fixtureId);
147
+ this.#assertId(callId);
148
+ if (generation !== this.#generation)
149
+ return this.#stale(fixtureId, generation, callId);
150
+ const generationSignal = this.#generationAbort.signal;
151
+ const signal = AbortSignal.any([context.signal, generationSignal]);
152
+ const hooked = this.#context(context, signal, generationSignal, fixtureId, generation, callId);
153
+ this.#active += 1;
154
+ this.#emit({ callId, fixtureId, generation, nonce: this.#descriptor.nonce, phase: "started" });
155
+ try {
156
+ const gate = this.#gates.get(this.#key(fixtureId, generation));
157
+ if (gate)
158
+ await this.#awaitGate(this.#key(fixtureId, generation), gate, hooked);
159
+ hooked.checkpoint();
160
+ const result = await operation(hooked);
161
+ hooked.checkpoint();
162
+ return result;
163
+ }
164
+ finally {
165
+ this.#active -= 1;
166
+ this.#emit({
167
+ callId,
168
+ fixtureId,
169
+ generation,
170
+ nonce: this.#descriptor.nonce,
171
+ phase: "terminal",
172
+ });
173
+ }
174
+ }
175
+ drainEvents() {
176
+ const events = Object.freeze(this.#events);
177
+ this.#events = [];
178
+ return events;
179
+ }
180
+ snapshot() {
181
+ return Object.freeze({
182
+ active: this.#active,
183
+ held: this.#gates.size,
184
+ abortListeners: this.#abortListeners,
185
+ staleSettlements: this.#staleSettlements,
186
+ });
187
+ }
188
+ #context(context, signal, generationSignal, fixtureId, generation, callId) {
189
+ const checkpoint = () => {
190
+ if (generationSignal.aborted || generation !== this.#generation)
191
+ this.#stale(fixtureId, generation, callId);
192
+ context.checkpoint();
193
+ };
194
+ return {
195
+ get sequence() {
196
+ return context.sequence;
197
+ },
198
+ get phase() {
199
+ return context.phase;
200
+ },
201
+ signal,
202
+ checkpoint,
203
+ markExecuting: () => context.markExecuting(),
204
+ enterCompletionCritical: () => {
205
+ checkpoint();
206
+ context.enterCompletionCritical();
207
+ },
208
+ };
209
+ }
210
+ async #awaitGate(key, gate, context) {
211
+ let rejectAbort;
212
+ const aborted = new Promise((_, reject) => (rejectAbort = reject));
213
+ const abort = () => {
214
+ if (this.#gates.get(key) === gate)
215
+ this.#gates.delete(key);
216
+ try {
217
+ context.checkpoint();
218
+ }
219
+ catch (error) {
220
+ rejectAbort(error);
221
+ }
222
+ };
223
+ context.signal.addEventListener("abort", abort, { once: true });
224
+ this.#abortListeners += 1;
225
+ try {
226
+ if (context.signal.aborted)
227
+ abort();
228
+ await Promise.race([gate.promise, aborted]);
229
+ }
230
+ finally {
231
+ context.signal.removeEventListener("abort", abort);
232
+ this.#abortListeners -= 1;
233
+ }
234
+ }
235
+ #stale(fixtureId, generation, callId) {
236
+ this.#staleSettlements += 1;
237
+ this.#emit({ callId, fixtureId, generation, nonce: this.#descriptor.nonce, phase: "stale" });
238
+ throw new H03TimeoutFixtureError();
239
+ }
240
+ #emit(event) {
241
+ this.#events.push(Object.freeze(event));
242
+ appendFileSync(path.join(this.#descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify(event)}\n`, { encoding: "utf8", flag: "a" });
243
+ }
244
+ #assertId(fixtureId) {
245
+ if (!FIXTURE_ID.test(fixtureId))
246
+ throw new Error("Invalid H-03 fixture id.");
247
+ }
248
+ #key(fixtureId, generation) {
249
+ return `${generation}:${fixtureId}`;
250
+ }
251
+ }
252
+ let configuredH05Controller;
253
+ let configuredH05Descriptor;
254
+ // Capture the closed command once before scheduler enqueue; mutable files cannot relabel it.
255
+ // prettier-ignore
256
+ export function captureConfiguredH05Command() { const raw = process.env.AST_H05_FIXTURE; if (raw === undefined)
257
+ return undefined; const descriptor = parseH05FixtureDescriptor(raw); if (!descriptor)
258
+ throw new Error("Invalid H-05 fixture descriptor."); if (configuredH05Descriptor) {
259
+ const sameGeneration = descriptor.generation === configuredH05Descriptor.generation, sameIdentity = descriptor.controlDirectory === configuredH05Descriptor.controlDirectory && descriptor.nonce === configuredH05Descriptor.nonce && descriptor.ownerToken === configuredH05Descriptor.ownerToken;
260
+ if (sameGeneration && !sameIdentity)
261
+ throw new Error("H-05 fixture descriptor drift.");
262
+ if (descriptor.generation < configuredH05Descriptor.generation)
263
+ throw new Error("H-05 fixture generation regressed.");
264
+ if (descriptor.generation > configuredH05Descriptor.generation) {
265
+ configuredH05Controller?.advance({ ...configuredH05Descriptor, generation: descriptor.generation });
266
+ configuredH05Controller = undefined;
267
+ configuredH05Descriptor = descriptor;
268
+ const replacement = new H05LifecycleFixtureController(descriptor);
269
+ configuredH05Controller = replacement;
270
+ }
271
+ }
272
+ else {
273
+ configuredH05Controller = new H05LifecycleFixtureController(descriptor);
274
+ configuredH05Descriptor = descriptor;
275
+ } if (!configuredH05Controller)
276
+ throw new Error("H-05 fixture generation is retired."); const value = JSON.parse(readFileSync(path.join(descriptor.controlDirectory, "command.json"), "utf8")); if (Object.keys(value).sort().join(",") !== "callId,correlationId,fixtureId,mode,nonce,ownerToken" || value.nonce !== descriptor.nonce || value.ownerToken !== descriptor.ownerToken || !["hold", "pass", "readback"].includes(value.mode))
277
+ throw new Error("Invalid H-05 fixture command."); const command = Object.freeze({ ...value }); const captured = configuredH05Controller.capture({ callId: command.callId, correlationId: command.correlationId, fixtureId: command.fixtureId }); return Object.freeze({ controller: configuredH05Controller, descriptor, command, captured }); }
278
+ // prettier-ignore
279
+ export async function runConfiguredH05Fixture(context, operation, configured) { const { controller, descriptor, command, captured } = configured; if (!controller.accepts(captured))
280
+ throw new H03TimeoutFixtureError(); if (command.mode === "readback") {
281
+ const snapshot = controller.snapshot(), eventsDrained = controller.drainEvents().length;
282
+ appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ ...snapshot, callId: captured.callId, correlationId: captured.correlationId, eventsDrained, fixtureId: captured.fixtureId, generation: captured.generation, phase: "readback" })}\n`);
283
+ const result = await operation(context);
284
+ if (!controller.accepts(captured))
285
+ throw new H03TimeoutFixtureError();
286
+ context.checkpoint();
287
+ return result;
288
+ } if (command.mode === "hold")
289
+ controller.hold(captured); return controller.run(captured, context, operation); }
290
+ let configuredController;
291
+ let configuredDescriptor;
292
+ const errorCommands = new WeakMap();
293
+ // Compact closed test-only wiring preserves PR3's hard review budget.
294
+ // prettier-ignore
295
+ function configuredFixture() { const descriptor = parseH03FixtureDescriptor(process.env.AST_H03_FIXTURE); if (!descriptor)
296
+ return undefined; if (!configuredController || configuredDescriptor?.generation !== descriptor.generation) {
297
+ configuredDescriptor = descriptor;
298
+ configuredController = new H03TimeoutFixtureController(descriptor);
299
+ } return { controller: configuredController, descriptor }; }
300
+ // prettier-ignore
301
+ function readCommand(descriptor) { const value = JSON.parse(readFileSync(path.join(descriptor.controlDirectory, "command.json"), "utf8")); if (Object.keys(value).sort().join(",") !== "callId,fixtureId,mode,nonce" || typeof value.callId !== "string" || !FIXTURE_ID.test(value.callId) || typeof value.fixtureId !== "string" || !FIXTURE_ID.test(value.fixtureId) || value.nonce !== descriptor.nonce || !["hold", "pass", "readback"].includes(value.mode))
302
+ throw new Error("Invalid H-03 fixture command."); return value; }
303
+ // Capture once before scheduler enqueue; later command.json writes cannot relabel this request.
304
+ // prettier-ignore
305
+ export function captureConfiguredH03Command() { const fixture = configuredFixture(); return fixture && Object.freeze({ ...fixture, command: readCommand(fixture.descriptor) }); }
306
+ // prettier-ignore
307
+ export function bindConfiguredH03Error(error, captured) { if (captured && typeof error === "object" && error !== null)
308
+ errorCommands.set(error, captured); }
309
+ export function configuredH05CorrelationId(error) {
310
+ const configured = typeof error === "object" && error !== null ? errorCommands.get(error) : undefined;
311
+ return configured && "captured" in configured ? configured.captured.correlationId : undefined;
312
+ }
313
+ // prettier-ignore
314
+ export async function runConfiguredH03Fixture(context, operation, captured) { const { controller, descriptor, command } = captured; if (command.mode === "readback") {
315
+ const snapshot = controller.snapshot(), eventsDrained = controller.drainEvents().length;
316
+ appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ ...snapshot, callId: command.callId, eventsDrained, fixtureId: command.fixtureId, generation: descriptor.generation, nonce: descriptor.nonce, phase: "readback" })}\n`);
317
+ return operation(context);
318
+ } if (command.mode === "hold")
319
+ controller.hold(command.fixtureId); return controller.run(command.fixtureId, descriptor.generation, context, operation, command.callId); }
320
+ // prettier-ignore
321
+ export function emitConfiguredH03HostEvent(phase, generation) { const descriptor = parseH03FixtureDescriptor(process.env.AST_H03_FIXTURE); if (!descriptor)
322
+ return; appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ fixtureId: "host", generation, nonce: descriptor.nonce, phase })}\n`, { encoding: "utf8", flag: "a" }); }
323
+ // prettier-ignore
324
+ export function emitConfiguredH03ErrorEvidence(text, error) { const configured = typeof error === "object" && error !== null ? errorCommands.get(error) : undefined; if (!configured)
325
+ return; const { descriptor, command } = configured; if (!("captured" in configured)) {
326
+ appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ callId: command.callId, fixtureId: command.fixtureId, generation: descriptor.generation, nonce: descriptor.nonce, phase: "error", result: { isError: true, error: { info: { name: error instanceof Error ? error.name : "Error" }, message: text } } })}\n`);
327
+ return;
328
+ } const { controller, captured } = configured; if (!controller.accepts(captured))
329
+ return; let publicError; try {
330
+ publicError = JSON.parse(text);
331
+ }
332
+ catch {
333
+ publicError = undefined;
334
+ } const source = publicError?.error, code = typeof source?.code === "string" && PUBLIC_ERROR_CODES.some((value) => value === source.code) ? source.code : "INTERNAL_ERROR", correlation_id = typeof source?.correlation_id === "string" && UUID.test(source.correlation_id) ? source.correlation_id : captured.correlationId, name = error instanceof Error && /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(error.name) ? error.name : "Error", message = typeof source?.message === "string" ? sanitizePublicText(source.message) : "An internal error occurred."; const identity = { callId: captured.callId, correlationId: captured.correlationId, fixtureId: captured.fixtureId, generation: captured.generation, ownerToken: captured.ownerToken, phase: "error", resources: controller.snapshot(), result: { isError: true, error: { code, message, correlation_id, info: { name } } } }; let serialized = JSON.stringify(identity); if (Buffer.byteLength(serialized) > 4096)
335
+ serialized = JSON.stringify({ ...identity, result: { ...identity.result, error: { ...identity.result.error, message: "Evidence message omitted: bounded record exceeded." } } }); appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${serialized}\n`); }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Project } from "ts-morph";
4
+ // prettier-ignore
5
+ import { bindConfiguredH03Error, captureConfiguredH03Command, captureConfiguredH05Command, runConfiguredH03Fixture, runConfiguredH05Fixture } from "./h03-timeout-fixture.js";
4
6
  import { InMemorySymbolIndex, SYMBOL_INDEX_SCHEMA_VERSION, } from "./symbol-index.js";
5
7
  import { addSymbolIndexRuntimeCount, createInitialSymbolIndexRuntimeObservability, readSymbolIndexPersistencePolicy, symbolIndexCachePath, symbolIndexPolicyKey, } from "./symbol-index-policy.js";
6
8
  import { openSQLiteSymbolIndexStore, quarantineSQLiteSymbolIndexFile, } from "./symbol-index-sqlite.js";
@@ -650,22 +652,33 @@ async function synchronizeSession(session, requestContext) {
650
652
  throw error;
651
653
  }
652
654
  }
653
- async function runSessionWithSyncPolicy(session, operation, allowSynchronizationFailure, requestContext) {
655
+ async function runSessionWithSyncPolicy(session, operation, allowSynchronizationFailure, requestContext, h03Fixture = false) {
654
656
  requestContext.checkpoint();
655
- return session.scheduler.run(async (operationContext) => {
656
- operationContext.checkpoint();
657
- try {
658
- await synchronizeSession(session, operationContext);
659
- }
660
- catch (error) {
661
- if (!allowSynchronizationFailure || isCooperativeInterruption(error))
662
- throw error;
663
- }
664
- operationContext.checkpoint();
665
- operationContext.markExecuting();
666
- session.lastAccessSequence = nextProjectSessionAccessSequence();
667
- return operation(session.context, operationContext);
668
- }, { signal: requestContext.signal });
657
+ const h05Command = h03Fixture ? captureConfiguredH05Command() : undefined;
658
+ // prettier-ignore
659
+ const h03Command = h05Command ? undefined : h03Fixture ? captureConfiguredH03Command() : undefined;
660
+ // prettier-ignore
661
+ try {
662
+ return await session.scheduler.run(async (operationContext) => {
663
+ operationContext.checkpoint();
664
+ try {
665
+ await synchronizeSession(session, operationContext);
666
+ }
667
+ catch (error) {
668
+ if (!allowSynchronizationFailure || isCooperativeInterruption(error))
669
+ throw error;
670
+ }
671
+ operationContext.checkpoint();
672
+ operationContext.markExecuting();
673
+ session.lastAccessSequence = nextProjectSessionAccessSequence();
674
+ const execute = (context) => operation(session.context, context);
675
+ return h05Command ? runConfiguredH05Fixture(operationContext, execute, h05Command) : h03Command ? runConfiguredH03Fixture(operationContext, execute, h03Command) : execute(operationContext);
676
+ }, { signal: requestContext.signal });
677
+ }
678
+ catch (error) {
679
+ bindConfiguredH03Error(error, h05Command ?? h03Command);
680
+ throw error;
681
+ }
669
682
  }
670
683
  export async function withProject(projectRoot, operation, requestContext = NO_REQUEST_CONTEXT) {
671
684
  requestContext.checkpoint();
@@ -707,7 +720,7 @@ function readProjectStatus(session, requestContext) {
707
720
  index_observability: { ...effectiveContext.symbolIndexObservability },
708
721
  operation_queue: getQueueStatus(session),
709
722
  };
710
- }, true, requestContext);
723
+ }, true, requestContext, true);
711
724
  }
712
725
  function peekSessionStatus(session) {
713
726
  const context = session.context;
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { types as utilTypes } from "node:util";
4
+ import { configuredH05CorrelationId, emitConfiguredH03ErrorEvidence, } from "./h03-timeout-fixture.js";
4
5
  export const PUBLIC_ERROR_CODES = Object.freeze([
5
6
  "INVALID_INPUT",
6
7
  "PROJECT_NOT_FOUND",
@@ -240,12 +241,13 @@ function serializePublicError(code, message, correlationId) {
240
241
  };
241
242
  return { envelope, text: JSON.stringify(envelope) };
242
243
  }
243
- export function renderPublicError(error, correlationId = randomUUID()) {
244
+ export function renderPublicError(error, correlationId = configuredH05CorrelationId(error) ?? randomUUID()) {
244
245
  const classified = classifyPublicError(error);
246
+ const emitted = (value) => (emitConfiguredH03ErrorEvidence(value.text, error),
247
+ value);
245
248
  const initial = serializePublicError(classified.code, classified.message, correlationId);
246
- if (Buffer.byteLength(initial.text, "utf8") <= MAX_PUBLIC_ERROR_RESPONSE_BYTES) {
247
- return initial;
248
- }
249
+ if (Buffer.byteLength(initial.text, "utf8") <= MAX_PUBLIC_ERROR_RESPONSE_BYTES)
250
+ return emitted(initial);
249
251
  let lowerBudget = Buffer.byteLength(TRUNCATION_SUFFIX, "utf8");
250
252
  let upperBudget = Buffer.byteLength(classified.message, "utf8") - 1;
251
253
  let best = serializePublicError(classified.code, TRUNCATION_SUFFIX, correlationId);
@@ -260,5 +262,5 @@ export function renderPublicError(error, correlationId = randomUUID()) {
260
262
  upperBudget = candidateBudget - 1;
261
263
  }
262
264
  }
263
- return best;
265
+ return emitted(best);
264
266
  }
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import packageMetadata from "../../package.json" with { type: "json" };
2
3
  export const RUNTIME_POLICY_ENV_KEYS = Object.freeze([
3
4
  "AST_MAX_PROJECT_SESSIONS",
4
5
  "AST_MAX_QUEUED_OPERATIONS_PER_PROJECT",
@@ -8,6 +9,7 @@ export const RUNTIME_POLICY_ENV_KEYS = Object.freeze([
8
9
  "AST_COMPILER_WORKER_MODE",
9
10
  "AST_COMPILER_WORKER_IDLE_TTL_MS",
10
11
  "AST_MCP_APPLY_GUARD",
12
+ "AST_MCP_TEXT_PROJECTION",
11
13
  ]);
12
14
  function parseBoundedInteger(rawValue, defaultValue, minimum, maximum) {
13
15
  if (rawValue === undefined)
@@ -27,8 +29,8 @@ function parseBoundedInteger(rawValue, defaultValue, minimum, maximum) {
27
29
  export function parseRuntimePolicy(environment) {
28
30
  const maxProjectSessions = parseBoundedInteger(environment.AST_MAX_PROJECT_SESSIONS, 8, 1, 32);
29
31
  const maxQueuedOperationsPerProject = parseBoundedInteger(environment.AST_MAX_QUEUED_OPERATIONS_PER_PROJECT, 32, 1, 256);
30
- const queueWaitTimeoutMs = parseBoundedInteger(environment.AST_QUEUE_WAIT_TIMEOUT_MS, 30_000, 100, 300_000);
31
- const operationDeadlineMs = parseBoundedInteger(environment.AST_OPERATION_DEADLINE_MS, 120_000, 1_000, 900_000);
32
+ const queueWaitTimeoutMs = parseBoundedInteger(environment.AST_QUEUE_WAIT_TIMEOUT_MS, packageMetadata.deepseekHarness.timeoutBudget.queueWaitMs, 100, 300_000);
33
+ const operationDeadlineMs = parseBoundedInteger(environment.AST_OPERATION_DEADLINE_MS, packageMetadata.deepseekHarness.timeoutBudget.executionDeadlineMs, 1_000, 900_000);
32
34
  const shutdownDrainTimeoutMs = parseBoundedInteger(environment.AST_SHUTDOWN_DRAIN_TIMEOUT_MS, 10_000, 100, 60_000);
33
35
  const rawWorkerMode = environment.AST_COMPILER_WORKER_MODE;
34
36
  const compilerWorkerMode = rawWorkerMode === "supervised" ? "supervised" : "in_process";
@@ -47,6 +49,13 @@ export function parseRuntimePolicy(environment) {
47
49
  : rawApplyGuard === "allow" || rawApplyGuard === "deny"
48
50
  ? "configured"
49
51
  : "invalid_mode";
52
+ const rawTextProjection = environment.AST_MCP_TEXT_PROJECTION;
53
+ const projectStructuredContentAsText = rawTextProjection === "canonical_json";
54
+ const textProjectionReason = rawTextProjection === undefined
55
+ ? "default"
56
+ : projectStructuredContentAsText
57
+ ? "configured"
58
+ : "invalid_mode";
50
59
  const reasons = Object.freeze({
51
60
  AST_MAX_PROJECT_SESSIONS: maxProjectSessions.reason,
52
61
  AST_MAX_QUEUED_OPERATIONS_PER_PROJECT: maxQueuedOperationsPerProject.reason,
@@ -56,6 +65,7 @@ export function parseRuntimePolicy(environment) {
56
65
  AST_COMPILER_WORKER_MODE: compilerWorkerModeReason,
57
66
  AST_COMPILER_WORKER_IDLE_TTL_MS: compilerWorkerIdleTtlMs.reason,
58
67
  AST_MCP_APPLY_GUARD: applyGuardReason,
68
+ AST_MCP_TEXT_PROJECTION: textProjectionReason,
59
69
  });
60
70
  return Object.freeze({
61
71
  maxProjectSessions: maxProjectSessions.value,
@@ -66,6 +76,7 @@ export function parseRuntimePolicy(environment) {
66
76
  compilerWorkerMode,
67
77
  compilerWorkerIdleTtlMs: compilerWorkerIdleTtlMs.value,
68
78
  denyApply,
79
+ projectStructuredContentAsText,
69
80
  reasons,
70
81
  });
71
82
  }
@@ -76,6 +87,46 @@ const WORKER_ENV_KEYS = [
76
87
  "AST_SYMBOL_INDEX_BUSY_TIMEOUT_MS",
77
88
  ];
78
89
  const MAX_WORKER_ENV_VALUE_LENGTH = 4096;
90
+ function parseClosedFixtureRecord(raw, expectedKeys) {
91
+ if (typeof raw !== "string" || raw.length > 1024 || raw.includes("\0"))
92
+ return undefined;
93
+ try {
94
+ const value = JSON.parse(raw);
95
+ if (!value || typeof value !== "object" || Array.isArray(value))
96
+ return undefined;
97
+ const record = value;
98
+ return Object.keys(record).sort().join(",") === expectedKeys ? record : undefined;
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ export function parseH03FixtureDescriptor(raw) {
105
+ const record = parseClosedFixtureRecord(raw, "controlDirectory,generation,nonce");
106
+ if (!record)
107
+ return undefined;
108
+ const { controlDirectory, nonce, generation } = record;
109
+ if (typeof controlDirectory !== "string" ||
110
+ controlDirectory.length > MAX_WORKER_ENV_VALUE_LENGTH ||
111
+ !path.isAbsolute(controlDirectory) ||
112
+ path.normalize(controlDirectory) !== controlDirectory ||
113
+ typeof nonce !== "string" ||
114
+ !/^[A-Za-z0-9_-]{16,64}$/.test(nonce) ||
115
+ !Number.isSafeInteger(generation) ||
116
+ generation < 1)
117
+ return undefined;
118
+ return Object.freeze({ controlDirectory, nonce, generation: generation });
119
+ }
120
+ export function parseH05FixtureDescriptor(raw) {
121
+ const record = parseClosedFixtureRecord(raw, "controlDirectory,generation,nonce,ownerToken");
122
+ if (!record)
123
+ return undefined;
124
+ const { ownerToken, ...baseValue } = record;
125
+ if (typeof ownerToken !== "string" || !/^[A-Za-z0-9_-]{16,64}$/.test(ownerToken))
126
+ return undefined;
127
+ const base = parseH03FixtureDescriptor(JSON.stringify(baseValue));
128
+ return base ? Object.freeze({ ...base, ownerToken }) : undefined;
129
+ }
79
130
  function validEnvironmentString(value) {
80
131
  return (typeof value === "string" &&
81
132
  value.length <= MAX_WORKER_ENV_VALUE_LENGTH &&
@@ -100,6 +151,20 @@ export function createCompilerWorkerSpawnSpec(workerEntryPath, environment) {
100
151
  }
101
152
  projected[key] = value;
102
153
  }
154
+ const fixture = environment.AST_H03_FIXTURE;
155
+ if (fixture !== undefined) {
156
+ const descriptor = parseH03FixtureDescriptor(fixture);
157
+ if (!descriptor)
158
+ return { ok: false, reason: "invalid_environment" };
159
+ projected.AST_H03_FIXTURE = JSON.stringify(descriptor);
160
+ }
161
+ const lifecycleFixture = environment.AST_H05_FIXTURE;
162
+ if (lifecycleFixture !== undefined) {
163
+ const descriptor = parseH05FixtureDescriptor(lifecycleFixture);
164
+ if (!descriptor)
165
+ return { ok: false, reason: "invalid_environment" };
166
+ projected.AST_H05_FIXTURE = JSON.stringify(descriptor);
167
+ }
103
168
  const policy = parseRuntimePolicy(environment);
104
169
  Object.assign(projected, {
105
170
  AST_MAX_PROJECT_SESSIONS: String(policy.maxProjectSessions),
@@ -110,6 +175,7 @@ export function createCompilerWorkerSpawnSpec(workerEntryPath, environment) {
110
175
  // Forward the parent's resolved apply-guard policy so the supervised worker
111
176
  // registers the same tool surface (deny-by-default fail-closed, allow opt-in).
112
177
  AST_MCP_APPLY_GUARD: policy.denyApply ? "deny" : "allow",
178
+ ...(policy.projectStructuredContentAsText ? { AST_MCP_TEXT_PROJECTION: "canonical_json" } : {}),
113
179
  });
114
180
  return {
115
181
  ok: true,
@@ -17,7 +17,7 @@ const CallSpinesInputSchema = z.object({
17
17
  max_nodes: z.number().int().min(1).max(MAX_IMPACT_NODES).default(DEFAULT_IMPACT_MAX_NODES),
18
18
  max_edges: z.number().int().min(1).max(MAX_IMPACT_EDGES).default(DEFAULT_IMPACT_MAX_EDGES),
19
19
  });
20
- const ExploreInputSchema = z
20
+ const ExploreInputObjectSchema = z
21
21
  .object({
22
22
  project_root: z
23
23
  .string()
@@ -65,7 +65,8 @@ const ExploreInputSchema = z
65
65
  .default(EXPLORE_DEFAULT_OMISSION_DETAIL_LIMIT)
66
66
  .describe("Maximum deterministic omission detail records; exact counts are always retained."),
67
67
  })
68
- .refine((input) => Boolean(input.query || input.file_path), {
68
+ .strip();
69
+ const ExploreInputSchema = ExploreInputObjectSchema.refine((input) => Boolean(input.query || input.file_path), {
69
70
  message: "Provide query, file_path, or both file_path and symbol_path.",
70
71
  path: ["query"],
71
72
  })
@@ -204,7 +205,7 @@ export function registerExplore(server) {
204
205
  server.registerTool(TOOL_NAME, {
205
206
  title: "Explore project context",
206
207
  description: "Composes bounded structural search, exact selectors, source evidence and compiler references without weakening the primitive AST tools. Returns bounded session freshness metadata for fresh, pending, stale, rebuilding or degraded state.",
207
- inputSchema: ExploreInputSchema,
208
+ inputSchema: ExploreInputObjectSchema,
208
209
  outputSchema: ExploreOutputSchema,
209
210
  annotations: {
210
211
  readOnlyHint: true,
@@ -215,6 +216,7 @@ export function registerExplore(server) {
215
216
  }, async ({ project_root, query, file_path, symbol_path, kinds, file_filter, detail, include_source, include_references, reference_detail, reference_limit, offset, limit, max_bytes, call_spines, omission_detail_limit, }, extra) => {
216
217
  const requestContext = createRequestContext(extra.signal);
217
218
  try {
219
+ ExploreInputSchema.parse({ project_root, query, file_path, symbol_path, call_spines });
218
220
  const output = await withProject(project_root, (context, operationContext) => buildExploreContext(context, {
219
221
  query,
220
222
  filePath: file_path,
@@ -73,7 +73,6 @@ export function registerGetImpact(server) {
73
73
  title: "Get bounded compiler-backed impact",
74
74
  description: "Traverses exact compiler-backed relationships around a symbol with explicit direction, depth, node and edge budgets. Returns bounded session freshness metadata and fails closed when exact relationships are not fresh. Read-only evidence only; it never prepares or applies mutations.",
75
75
  inputSchema: AstGetImpactInputSchema,
76
- outputSchema: ImpactOutputSchema,
77
76
  annotations: {
78
77
  readOnlyHint: true,
79
78
  destructiveHint: false,
@@ -2,10 +2,15 @@ import { Buffer } from "node:buffer";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { decode, encode } from "@toon-format/toon";
4
4
  import { z } from "zod";
5
+ import { COMPILER_WORKER_MAX_RESULT_BYTES, fitsCompilerWorkerResponseResult, } from "../services/compiler-worker-protocol.js";
5
6
  import { createProjectIdentity } from "../services/project-status.js";
6
7
  import { renderPublicError } from "../services/public-errors.js";
7
8
  import { emitToolFailureEvent } from "../services/runtime-logger.js";
8
9
  export const MAX_TOON_RESULT_BYTES = 10 * 1024 * 1024;
10
+ // Leave room for the unchanged structured value plus JSON-RPC framing under
11
+ // the supervised worker's 256 KiB line limit.
12
+ export const MAX_TEXT_PROJECTION_BYTES = 112 * 1024;
13
+ export const MAX_PROJECTED_RESULT_BYTES = COMPILER_WORKER_MAX_RESULT_BYTES;
9
14
  export const ToolOutputFormatSchema = z.enum(["json", "toon"]).default("json");
10
15
  export const ToolOutputFormatInputSchema = {
11
16
  output_format: ToolOutputFormatSchema.describe("Result representation. TOON wraps one compact encoded value in structuredContent."),
@@ -24,6 +29,49 @@ export function createToolErrorContext(toolName, projectRoot) {
24
29
  return { toolName };
25
30
  }
26
31
  }
32
+ /**
33
+ * Add a deterministic model-visible JSON projection without replacing the
34
+ * canonical structured value. Existing content wins, and oversized values get
35
+ * an explicit bounded marker when the complete framed result has room for it;
36
+ * otherwise the unchanged structured-only result remains authoritative.
37
+ */
38
+ export function projectStructuredContentAsText(result, enabled, maxBytes = MAX_TEXT_PROJECTION_BYTES) {
39
+ if (!enabled ||
40
+ result.isError === true ||
41
+ result.content.length > 0 ||
42
+ result.structuredContent === undefined) {
43
+ return result;
44
+ }
45
+ let text;
46
+ try {
47
+ text =
48
+ JSON.stringify(result.structuredContent) ??
49
+ "(structured result is not JSON-serializable for model-visible projection)";
50
+ }
51
+ catch {
52
+ text = "(structured result is not JSON-serializable for model-visible projection)";
53
+ }
54
+ const byteLength = Buffer.byteLength(text, "utf8");
55
+ if (byteLength > maxBytes) {
56
+ text = `(structured result exceeds the ${maxBytes}-byte model-visible projection limit; canonical structuredContent remains available)`;
57
+ }
58
+ let projected = {
59
+ ...result,
60
+ content: [{ type: "text", text }],
61
+ };
62
+ if (fitsCompilerWorkerResponseResult(projected))
63
+ return projected;
64
+ projected = {
65
+ ...result,
66
+ content: [
67
+ {
68
+ type: "text",
69
+ text: `(structured result exceeds the ${MAX_PROJECTED_RESULT_BYTES}-byte complete projection limit; canonical structuredContent remains available)`,
70
+ },
71
+ ],
72
+ };
73
+ return fitsCompilerWorkerResponseResult(projected) ? projected : result;
74
+ }
27
75
  export function structuredResult(structuredContent) {
28
76
  return { content: [], structuredContent };
29
77
  }
@@ -15,7 +15,12 @@ export async function runStdioServer(options = {}) {
15
15
  const runtimeActivity = options.runtimeActivity ?? new RuntimeActivityTracker();
16
16
  runtimeActivity.prepareForStartup();
17
17
  const runtimePolicy = options.runtimePolicy ?? readRuntimePolicy();
18
- const server = options.server ?? createServer({ runtimeActivity, denyApply: runtimePolicy.denyApply });
18
+ const server = options.server ??
19
+ createServer({
20
+ runtimeActivity,
21
+ denyApply: runtimePolicy.denyApply,
22
+ projectStructuredContentAsText: runtimePolicy.projectStructuredContentAsText,
23
+ });
19
24
  const transport = options.transport ?? new StdioServerTransport();
20
25
  const send = transport.send.bind(transport);
21
26
  transport.send = (message, sendOptions) => runtimeActivity.trackSend(() => send(message, sendOptions));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ast-mcp-server",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Structural code MCP server and batch CLI for compact TypeScript/JavaScript reads and hash-bound AST edits.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -34,7 +34,13 @@
34
34
  "deepseekHarness": {
35
35
  "revision": "cd5ef8148158c3a752a658978873241fdf8e2bbc",
36
36
  "tag": "dsh-v0.1.2-alpha.1",
37
- "mcpClientVersion": "0.1.2-alpha.1"
37
+ "mcpClientVersion": "0.1.2-alpha.1",
38
+ "timeoutBudget": {
39
+ "queueWaitMs": 30000,
40
+ "executionDeadlineMs": 120000,
41
+ "marginMs": 15000,
42
+ "outerToolCallMs": 180000
43
+ }
38
44
  },
39
45
  "main": "dist/index.js",
40
46
  "bin": {
@@ -110,5 +116,5 @@
110
116
  "vitest": "^4.1.10",
111
117
  "yaml": "^2.9.0"
112
118
  },
113
- "gitHead": "75302189733f40aba6a36a8379c5b1f65fc3bd84"
119
+ "gitHead": "27b80a3da169b473a3b5c5dfea69ed52903ed4c7"
114
120
  }