@reticlehq/server 2.10.0 → 2.12.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.
- package/SKILL.md +73 -11
- package/dist/bridge/bridge.d.ts +0 -13
- package/dist/bridge/bridge.js +1 -1
- package/dist/bridge/bridge.test-harness.d.ts +2 -0
- package/dist/bridge/bridge.test-harness.js +8 -1
- package/dist/capsule/capsule-store.d.ts +2 -1
- package/dist/capsule/capsule-store.js +1 -1
- package/dist/capsule/causal-summary.d.ts +2 -2
- package/dist/chromium-launch-options.d.ts +28 -0
- package/dist/chromium-launch-options.js +29 -0
- package/dist/cli/cli-doctor.js +8 -0
- package/dist/cli/cli-flow-commands.d.ts +3 -1
- package/dist/cli/cli-flow-commands.js +34 -4
- package/dist/cli/cli-kill.d.ts +2 -1
- package/dist/cli/cli-parse.d.ts +3 -1
- package/dist/cli/cli-parse.js +29 -7
- package/dist/cli/cli-update-commands.js +20 -1
- package/dist/cli/cli-verify.d.ts +1 -1
- package/dist/cli/cli-verify.js +2 -2
- package/dist/cli/cloud-cli.js +12 -6
- package/dist/cli/config-discovery.d.ts +2 -1
- package/dist/cli/doctor-rows.d.ts +3 -1
- package/dist/cli/doctor-rows.js +3 -1
- package/dist/cli/gate-exit.d.ts +21 -0
- package/dist/cli/gate-exit.js +21 -0
- package/dist/cli/gate-hook-message.d.ts +33 -0
- package/dist/cli/gate-hook-message.js +46 -0
- package/dist/cli/mcp-command.d.ts +25 -0
- package/dist/cli/mcp-command.js +143 -0
- package/dist/cli/sibling-ports.d.ts +37 -0
- package/dist/cli/sibling-ports.js +56 -0
- package/dist/cli.js +37 -110
- package/dist/cloud/cloud-config.d.ts +2 -1
- package/dist/cloud/cloud-sync.d.ts +11 -0
- package/dist/cloud/cloud-sync.js +32 -0
- package/dist/crawl/crawl.d.ts +9 -13
- package/dist/crawl/crawl.js +20 -3
- package/dist/daemon/daemon-resolve.d.ts +79 -0
- package/dist/daemon/daemon-resolve.js +118 -0
- package/dist/daemon/free-port.d.ts +29 -0
- package/dist/daemon/free-port.js +55 -0
- package/dist/daemon/heartbeat.d.ts +0 -4
- package/dist/daemon/heartbeat.js +2 -2
- package/dist/dev/stale-issue-guard.d.ts +41 -0
- package/dist/dev/stale-issue-guard.js +75 -0
- package/dist/domain/domain-tools.js +3 -2
- package/dist/events/contradictions.d.ts +41 -0
- package/dist/events/contradictions.js +125 -12
- package/dist/events/declared.d.ts +14 -0
- package/dist/events/declared.js +33 -0
- package/dist/events/echo-mismatch.js +14 -0
- package/dist/events/observed-in-window.d.ts +12 -0
- package/dist/events/observed-in-window.js +17 -0
- package/dist/events/predicate-asks.d.ts +30 -0
- package/dist/events/predicate-asks.js +48 -0
- package/dist/events/predicate-eval.js +254 -25
- package/dist/events/predicate-parse.d.ts +0 -6
- package/dist/events/predicate-parse.js +51 -5
- package/dist/events/predicate-schema.d.ts +62 -0
- package/dist/events/predicate-schema.js +162 -4
- package/dist/events/predicate.d.ts +15 -0
- package/dist/events/predicate.js +41 -2
- package/dist/flows/coverage.d.ts +0 -8
- package/dist/flows/coverage.js +1 -1
- package/dist/flows/decision.d.ts +5 -1
- package/dist/flows/decision.js +33 -7
- package/dist/flows/flake.d.ts +0 -2
- package/dist/flows/flake.js +1 -1
- package/dist/flows/flow-intent.d.ts +52 -0
- package/dist/flows/flow-intent.js +123 -0
- package/dist/flows/flow-replay-run.js +27 -4
- package/dist/flows/flow-success.js +15 -3
- package/dist/flows/flow-tools.js +65 -8
- package/dist/flows/flows.d.ts +6 -1
- package/dist/flows/flows.js +34 -22
- package/dist/flows/predicate-to-expect.js +14 -3
- package/dist/flows/server-verify.js +16 -2
- package/dist/flows/verify-change-tools.js +9 -2
- package/dist/honesty/blind-spots.d.ts +8 -16
- package/dist/honesty/blind-spots.js +18 -5
- package/dist/honesty/feature-capture.d.ts +150 -0
- package/dist/honesty/feature-capture.js +233 -0
- package/dist/honesty/gap-ledger.d.ts +52 -0
- package/dist/honesty/gap-ledger.js +63 -0
- package/dist/honesty/instrumentation-gaps.d.ts +79 -0
- package/dist/honesty/instrumentation-gaps.js +77 -0
- package/dist/honesty/observability.d.ts +37 -0
- package/dist/honesty/observability.js +40 -0
- package/dist/honesty/tool-hit-rate.d.ts +86 -0
- package/dist/honesty/tool-hit-rate.js +90 -0
- package/dist/honesty/undeclared-change.d.ts +51 -0
- package/dist/honesty/undeclared-change.js +56 -0
- package/dist/honesty/verified.d.ts +15 -0
- package/dist/honesty/verified.js +68 -4
- package/dist/http-server.d.ts +0 -1
- package/dist/http-server.js +1 -1
- package/dist/hunt/hunt-report.d.ts +0 -2
- package/dist/hunt/hunt-report.js +1 -1
- package/dist/impact/impact-store.d.ts +0 -5
- package/dist/impact/impact-store.js +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +41 -0
- package/dist/init/agent-rules.d.ts +17 -0
- package/dist/init/agent-rules.js +31 -1
- package/dist/init/capabilities.d.ts +0 -2
- package/dist/init/capabilities.js +1 -1
- package/dist/init/closing-hint.d.ts +36 -0
- package/dist/init/closing-hint.js +120 -0
- package/dist/init/confirm.d.ts +0 -25
- package/dist/init/confirm.js +3 -3
- package/dist/init/cra.d.ts +14 -1
- package/dist/init/cra.js +43 -12
- package/dist/init/dev-script.d.ts +48 -0
- package/dist/init/dev-script.js +63 -0
- package/dist/init/mcp.d.ts +16 -5
- package/dist/init/mcp.js +26 -10
- package/dist/init/node-io.js +3 -0
- package/dist/init/plan-framework.d.ts +0 -5
- package/dist/init/plan-framework.js +83 -9
- package/dist/init/plan.d.ts +10 -30
- package/dist/init/plan.js +30 -15
- package/dist/init/refresh-rules.d.ts +39 -0
- package/dist/init/refresh-rules.js +51 -0
- package/dist/init/run.d.ts +2 -10
- package/dist/init/run.js +26 -68
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +33 -10
- package/dist/init/snippets.js +53 -10
- package/dist/input/real-input.d.ts +2 -0
- package/dist/input/real-input.js +3 -2
- package/dist/input/scroll-find.d.ts +7 -4
- package/dist/input/scroll-find.js +63 -25
- package/dist/intent/inline-intent.d.ts +46 -0
- package/dist/intent/inline-intent.js +103 -0
- package/dist/intent/intent-store.d.ts +37 -0
- package/dist/intent/intent-store.js +112 -0
- package/dist/intent/intent-tools.d.ts +2 -0
- package/dist/intent/intent-tools.js +80 -0
- package/dist/intent/open-intents.d.ts +21 -0
- package/dist/intent/open-intents.js +25 -0
- package/dist/journal/envelope.d.ts +2 -2
- package/dist/journal/journal-recorder.d.ts +23 -0
- package/dist/journal/journal-recorder.js +35 -0
- package/dist/license/license.d.ts +8 -2
- package/dist/license/license.js +10 -15
- package/dist/mcp/attach-memory.d.ts +67 -0
- package/dist/mcp/attach-memory.js +142 -0
- package/dist/mcp/mcp-proxy.d.ts +0 -7
- package/dist/mcp/mcp-proxy.js +14 -2
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +45 -23
- package/dist/mcp/server-instructions.js +45 -3
- package/dist/mcp/tool-catalog-cache.d.ts +8 -0
- package/dist/mcp/tool-catalog-cache.js +10 -0
- package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
- package/dist/oracles/flow-instrument-gaps.js +4 -5
- package/dist/oracles/self-instrument.d.ts +9 -9
- package/dist/oracles/self-instrument.js +19 -13
- package/dist/pool/browser-pool.d.ts +12 -1
- package/dist/pool/browser-pool.js +31 -1
- package/dist/pool/playwright-launcher.js +3 -1
- package/dist/project/artifact-root.d.ts +83 -0
- package/dist/project/artifact-root.js +112 -0
- package/dist/project/fs-port.d.ts +3 -0
- package/dist/project/fs-port.js +3 -2
- package/dist/project/memory-fs.d.ts +9 -0
- package/dist/project/memory-fs.js +93 -0
- package/dist/project/project-store.d.ts +17 -0
- package/dist/project/project-store.js +33 -0
- package/dist/project/project-tools.js +3 -32
- package/dist/project/remember-project.d.ts +24 -0
- package/dist/project/remember-project.js +40 -0
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/project/session-root.d.ts +15 -0
- package/dist/project/session-root.js +31 -0
- package/dist/runs/context-tools.d.ts +2 -0
- package/dist/runs/context-tools.js +89 -0
- package/dist/runs/run-context.d.ts +40 -0
- package/dist/runs/run-context.js +158 -0
- package/dist/runs/run-diff.d.ts +2 -1
- package/dist/runs/run-tools.js +2 -1
- package/dist/runs/verification-sync.js +2 -2
- package/dist/session/dev-server-probe.d.ts +0 -8
- package/dist/session/dev-server-probe.js +1 -1
- package/dist/session/last-act.d.ts +7 -1
- package/dist/session/last-act.js +2 -2
- package/dist/session/lease-availability.d.ts +40 -0
- package/dist/session/lease-availability.js +43 -0
- package/dist/session/live-control-tools.js +1 -42
- package/dist/session/no-session-diagnosis.d.ts +20 -0
- package/dist/session/no-session-diagnosis.js +41 -12
- package/dist/session/no-session-next-action.d.ts +5 -0
- package/dist/session/no-session-next-action.js +35 -3
- package/dist/session/no-session-watch.d.ts +8 -0
- package/dist/session/no-session-watch.js +83 -65
- package/dist/session/output-budget.js +4 -1
- package/dist/session/session-health.d.ts +9 -0
- package/dist/session/session-health.js +23 -0
- package/dist/session/session-manager.js +50 -1
- package/dist/session/session-successor.d.ts +37 -0
- package/dist/session/session-successor.js +78 -0
- package/dist/session/session.d.ts +40 -1
- package/dist/session/session.js +84 -2
- package/dist/session/stall-clock.d.ts +18 -0
- package/dist/session/stall-clock.js +48 -0
- package/dist/telemetry/automation-hint.d.ts +2 -1
- package/dist/telemetry/daemon-telemetry.js +12 -23
- package/dist/telemetry/feedback-outbox.d.ts +2 -1
- package/dist/telemetry/project-profile.d.ts +0 -8
- package/dist/telemetry/project-profile.js +1 -1
- package/dist/telemetry/run-telemetry.d.ts +0 -2
- package/dist/telemetry/run-telemetry.js +1 -1
- package/dist/telemetry/session-metrics.d.ts +1 -1
- package/dist/telemetry/session-metrics.js +1 -1
- package/dist/telemetry/telemetry.d.ts +12 -3
- package/dist/telemetry/telemetry.js +25 -5
- package/dist/telemetry/verification-of.js +2 -2
- package/dist/tools/act-tools.js +159 -24
- package/dist/tools/assert-source.d.ts +22 -0
- package/dist/tools/assert-source.js +53 -0
- package/dist/tools/assert-verdict.d.ts +21 -2
- package/dist/tools/assert-verdict.js +59 -4
- package/dist/tools/browser-tools.js +0 -20
- package/dist/tools/complete-snapshot.d.ts +48 -0
- package/dist/tools/complete-snapshot.js +75 -0
- package/dist/tools/contract-tools.js +5 -3
- package/dist/tools/coverage-tools.d.ts +10 -1
- package/dist/tools/coverage-tools.js +124 -37
- package/dist/tools/dynamic-tools.js +29 -8
- package/dist/tools/error-recovery.d.ts +24 -14
- package/dist/tools/error-recovery.js +70 -9
- package/dist/tools/feedback-tools.d.ts +1 -1
- package/dist/tools/feedback-tools.js +2 -2
- package/dist/tools/invoke-tool.js +62 -2
- package/dist/tools/lease-hint.d.ts +10 -0
- package/dist/tools/lease-hint.js +53 -4
- package/dist/tools/lease-tools.d.ts +5 -1
- package/dist/tools/lease-tools.js +22 -8
- package/dist/tools/navigate-arrival.d.ts +0 -2
- package/dist/tools/navigate-arrival.js +1 -1
- package/dist/tools/observe-tools.js +70 -27
- package/dist/tools/read-tools.js +5 -3
- package/dist/tools/real-input-attempt.d.ts +22 -0
- package/dist/tools/real-input-attempt.js +153 -1
- package/dist/tools/reconcile-tools.js +18 -4
- package/dist/tools/resolve-target.d.ts +0 -7
- package/dist/tools/sdk-marker-probe.d.ts +29 -0
- package/dist/tools/sdk-marker-probe.js +57 -0
- package/dist/tools/snapshot-delta.d.ts +1 -1
- package/dist/tools/tool-kit.d.ts +28 -1
- package/dist/tools/tool-kit.js +16 -0
- package/dist/tools/tool-names.d.ts +3 -0
- package/dist/tools/tool-names.js +3 -0
- package/dist/tools/tool-surface.d.ts +85 -6
- package/dist/tools/tool-surface.js +122 -2
- package/dist/tools/tools.js +22 -4
- package/dist/tools/unadvertised-help.js +4 -4
- package/dist/tools/verify-next-baton.d.ts +53 -0
- package/dist/tools/verify-next-baton.js +52 -0
- package/dist/update/update-nudge.d.ts +7 -10
- package/dist/update/update-nudge.js +12 -3
- package/dist/update/updater.d.ts +1 -1
- package/docs/README.md +3 -1
- package/docs/cli/cloud.mdx +1 -1
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/verify.mdx +4 -5
- package/docs/desktop.mdx +1 -1
- package/docs/docs.json +3 -3
- package/docs/enterprise.md +3 -1
- package/docs/gates.md +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/license-activation.mdx +145 -0
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/server.mdx +1 -1
- package/docs/packages.mdx +8 -6
- package/docs/platform-integration.md +17 -0
- package/docs/predicates.mdx +8 -0
- package/docs/reticle-manual.md +106 -0
- package/docs/telemetry-contract.md +1 -12
- package/docs/telemetry-events.mdx +1 -34
- package/docs/telemetry.md +0 -1
- package/docs/tools-tools-and-run.mdx +2 -4
- package/docs/troubleshooting.mdx +9 -0
- package/docs/usage.md +3 -2
- package/package.json +2 -2
- package/dist/session/session-readiness.d.ts +0 -34
- package/dist/session/session-readiness.js +0 -36
- package/dist/telemetry/instrumentation-stall.d.ts +0 -57
- package/dist/telemetry/instrumentation-stall.js +0 -103
package/dist/mcp/mcp-proxy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
2
|
import { localInitializeResponse, isHandshakeLine, drainLines, MAX_STDIN_LINE_BYTES, } from './proxy-handshake.js';
|
|
3
|
-
import { ToolCatalogCache } from './tool-catalog-cache.js';
|
|
3
|
+
import { isToolsListRequest, ToolCatalogCache } from './tool-catalog-cache.js';
|
|
4
|
+
import { rememberEnumerated, rememberProxyStarted } from './attach-memory.js';
|
|
4
5
|
import { toolsChangedNotification } from './proxy-handshake.js';
|
|
5
6
|
import { LOOPBACK_HOST, MCP_SSE_PATH, MCP_SHUTDOWN_EVENT, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
|
|
6
7
|
import { SseFrameParser } from './sse-frame-parser.js';
|
|
@@ -141,7 +142,7 @@ const TRANSPORT_LOSS_CODE = -32001;
|
|
|
141
142
|
* `nextStep` is appended when the caller knows one. Without it this reply describes a condition and
|
|
142
143
|
* stops there, which is all a first-run user got out of their first ever tool call.
|
|
143
144
|
*/
|
|
144
|
-
|
|
145
|
+
function transportLossReply(id, reason, nextStep) {
|
|
145
146
|
return JSON.stringify({
|
|
146
147
|
jsonrpc: '2.0',
|
|
147
148
|
id,
|
|
@@ -347,6 +348,11 @@ export function startMcpProxy(port,
|
|
|
347
348
|
*/
|
|
348
349
|
ensureDaemon) {
|
|
349
350
|
return new Promise((_resolve, reject) => {
|
|
351
|
+
// A client just started us, which is the only honest evidence that Reticle is registered with
|
|
352
|
+
// one at all: a config entry proves somebody wrote JSON, not that a client ever read it. Paired
|
|
353
|
+
// with the `tools/list` record below, this is what lets `status` name the state in between
|
|
354
|
+
// "never registered" and "connected" — see attach-memory.ts.
|
|
355
|
+
rememberProxyStarted(reticleStateHome(), port);
|
|
350
356
|
// Declared before the SSE handler that fills it: the handler is a closure created below but
|
|
351
357
|
// invoked on the first daemon frame, and a `const` referenced before its declaration executes is
|
|
352
358
|
// a runtime throw, not a type error.
|
|
@@ -767,6 +773,12 @@ ensureDaemon) {
|
|
|
767
773
|
continue;
|
|
768
774
|
replay.observeOutbound(trimmed);
|
|
769
775
|
pending.observeOutbound(trimmed);
|
|
776
|
+
// The ARRIVAL of the request, not the delivery of an answer: "this client asked for the tool
|
|
777
|
+
// list" is the fact `status` reports on, and a request that arrived and then failed is a
|
|
778
|
+
// different problem with its own diagnosis. Recording the answer instead would let a host
|
|
779
|
+
// that never asks look identical to one that asked and was refused.
|
|
780
|
+
if (isToolsListRequest(trimmed))
|
|
781
|
+
rememberEnumerated(reticleStateHome(), port);
|
|
770
782
|
const action = onClientRequest(postUrl !== null, dormant);
|
|
771
783
|
if (action === OnRequest.SEND && postUrl !== null) {
|
|
772
784
|
forward(postUrl, trimmed);
|
package/dist/mcp/mcp.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { z } from 'zod';
|
|
|
3
3
|
import { type ToolDeps } from '../tools/tools.js';
|
|
4
4
|
import type { ToolDef } from '../tools/tools.js';
|
|
5
5
|
import { type ToolSurface } from '../tools/tool-surface.js';
|
|
6
|
+
/** The JSON-RPC method the SDK registers its tool dispatcher under. */
|
|
7
|
+
export declare const CALL_TOOL_METHOD = "tools/call";
|
|
6
8
|
/**
|
|
7
9
|
* Merge the runtime-spliced envelope (health/lease/age/control) into a session-bound tool's declared
|
|
8
10
|
* outputSchema so structuredContent validation keeps those fields instead of dropping them. The tool's
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import {
|
|
4
|
+
import { isPredicateParam } from '../events/predicate-eval.js';
|
|
5
5
|
import { isToonable, resultToToon, MCP_SERVER_NAME } from '@reticlehq/core';
|
|
6
6
|
import { TOOLS } from '../tools/tools.js';
|
|
7
7
|
import { filterTools, describeToolSurface, TOOL_SURFACE, } from '../tools/tool-surface.js';
|
|
@@ -15,7 +15,19 @@ import { resultIsError } from './mcp-is-error.js';
|
|
|
15
15
|
import { buildServerInstructions } from './server-instructions.js';
|
|
16
16
|
import { unadvertisedToolHelp } from '../tools/unadvertised-help.js';
|
|
17
17
|
/** The JSON-RPC method the SDK registers its tool dispatcher under. */
|
|
18
|
-
const CALL_TOOL_METHOD = 'tools/call';
|
|
18
|
+
export const CALL_TOOL_METHOD = 'tools/call';
|
|
19
|
+
/**
|
|
20
|
+
* What we say when an SDK internal these patches ride on is no longer there.
|
|
21
|
+
*
|
|
22
|
+
* LOG rather than THROW, deliberately. Both patches improve an ERROR MESSAGE; neither is load
|
|
23
|
+
* bearing for a single tool call, and a throw here runs at server construction, so a renamed
|
|
24
|
+
* internal in a minor SDK bump would take the whole MCP server down for every user rather than
|
|
25
|
+
* degrade one message. That trade is backwards. The place a shape change must fail is the gate —
|
|
26
|
+
* `sdk-private-shape.test.ts` asserts both shapes and reddens CI on the bump — and this line is the
|
|
27
|
+
* backstop for the one case the gate cannot see: a user whose installed SDK is not the one CI
|
|
28
|
+
* resolved. The old behaviour, a bare `return`, gave neither.
|
|
29
|
+
*/
|
|
30
|
+
const SDK_SHAPE_MISSING = 'sdk_internal_missing';
|
|
19
31
|
import { log } from '../log.js';
|
|
20
32
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
21
33
|
import { setMcpClientNameHook } from '../telemetry/feedback-context.js';
|
|
@@ -73,23 +85,6 @@ export function firstSentence(description) {
|
|
|
73
85
|
* Across the three tools that take a predicate that is 72% of the entire advertised input schema — a
|
|
74
86
|
* cost re-sent on every request, forever, to describe a grammar most calls use one variant of.
|
|
75
87
|
*/
|
|
76
|
-
/**
|
|
77
|
-
* Is this parameter ACTUALLY the predicate union?
|
|
78
|
-
*
|
|
79
|
-
* This used to be a name check — `new Set(['predicate', 'until'])` — and `until` is overloaded on
|
|
80
|
-
* this surface: the act/assert family means a predicate by it, while reticle_observe / _network /
|
|
81
|
-
* _console mean a NUMBER, an upper cursor bound. So all three had their numeric parameter advertised
|
|
82
|
-
* to the agent as `Predicate object: { kind, ...fields }`, typed as a record, under the DEFAULT
|
|
83
|
-
* profile. An agent believing the schema passes an object, `asNumber` yields undefined, the bound is
|
|
84
|
-
* silently dropped and the tool answers over the wrong window — a wrong answer wearing the shape of
|
|
85
|
-
* an answer.
|
|
86
|
-
*
|
|
87
|
-
* Keying on the schema cannot make that mistake: a parameter is compacted because it IS a predicate.
|
|
88
|
-
*/
|
|
89
|
-
function isPredicateParam(schema) {
|
|
90
|
-
const inner = schema instanceof z.ZodOptional ? schema.unwrap() : schema;
|
|
91
|
-
return inner === PredicateSchema || inner === PredicateSchema.optional();
|
|
92
|
-
}
|
|
93
88
|
/**
|
|
94
89
|
* What a lean profile advertises in place of the full predicate union.
|
|
95
90
|
*
|
|
@@ -246,7 +241,12 @@ export function advertisedConfig(tool, advertised, profile) {
|
|
|
246
241
|
// `verify` is lean for the same reason `default` is, and more so: it exists to make a verdict
|
|
247
242
|
// cheap, and it was serving FULL descriptions plus outputSchema because the check named only the
|
|
248
243
|
// default. That put 8,207 bytes on the wire for three tools.
|
|
249
|
-
|
|
244
|
+
// `lean` here is the ADJECTIVE (terse descriptions, no output schemas), which every trimmed surface
|
|
245
|
+
// wants — including TOOL_SURFACE.LEAN, the profile that borrows the word. A trimmed surface serving
|
|
246
|
+
// full prose plus output schemas is the exact defect that put 8,207 bytes on `verify`'s three tools.
|
|
247
|
+
const lean = profile === TOOL_SURFACE.DEFAULT ||
|
|
248
|
+
profile === TOOL_SURFACE.VERIFY ||
|
|
249
|
+
profile === TOOL_SURFACE.LEAN;
|
|
250
250
|
const terse = lean && !isMetaTool;
|
|
251
251
|
// The first advertised tool carrying a predicate spells the grammar out; the rest point at it.
|
|
252
252
|
const anchor = advertised.find((t) => Object.values(t.inputSchema).some((schema) => isPredicateParam(schema)))?.name;
|
|
@@ -270,8 +270,10 @@ function unknownKeys(args, allowed) {
|
|
|
270
270
|
export function installFriendlyArgErrors(server, examples, allowedKeys = new Map()) {
|
|
271
271
|
const target = server;
|
|
272
272
|
const original = target.validateToolInput;
|
|
273
|
-
if (typeof original !== 'function')
|
|
273
|
+
if (typeof original !== 'function') {
|
|
274
|
+
log(SDK_SHAPE_MISSING, { internal: 'validateToolInput', lost: 'friendly argument errors' });
|
|
274
275
|
return;
|
|
276
|
+
}
|
|
275
277
|
target.validateToolInput = async (tool, args, toolName) => {
|
|
276
278
|
// An UNKNOWN key is the failure zod cannot catch, because object schemas are non-strict: the key
|
|
277
279
|
// is silently dropped and the tool answers as if it had not been asked. Measured live —
|
|
@@ -281,8 +283,14 @@ export function installFriendlyArgErrors(server, examples, allowedKeys = new Map
|
|
|
281
283
|
const unknown = unknownKeys(args, allowedKeys.get(toolName));
|
|
282
284
|
if (unknown.length > 0) {
|
|
283
285
|
const example = examples.get(toolName);
|
|
286
|
+
// What the tool DOES declare, in the same breath as what it does not. Naming only the bad key
|
|
287
|
+
// leaves the caller guessing again, and the next guess costs another round trip — which is the
|
|
288
|
+
// whole shape of the failure being fixed here. The list is already in hand, and for a tool
|
|
289
|
+
// with no example (the meta-tools) it is the only thing standing between a typo and a retry.
|
|
290
|
+
const declared = [...(allowedKeys.get(toolName) ?? [])].join(', ');
|
|
284
291
|
throw new McpError(ErrorCode.InvalidParams, `Unknown ${1 === unknown.length ? 'parameter' : 'parameters'} for ${toolName}: ${unknown.join(', ')}. ` +
|
|
285
292
|
`They were NOT applied — a result computed without them would look like an answer. ` +
|
|
293
|
+
(0 === declared.length ? '' : `${toolName} declares: ${declared}. `) +
|
|
286
294
|
(example === undefined
|
|
287
295
|
? `Call reticle_tools { names: ["${toolName}"] } for its parameters.`
|
|
288
296
|
: `A valid call looks like: ${toolName} ${example}`));
|
|
@@ -347,8 +355,13 @@ function installUnadvertisedToolHelp(server, advertised, known) {
|
|
|
347
355
|
const inner = server.server;
|
|
348
356
|
const handlers = inner._requestHandlers;
|
|
349
357
|
const original = handlers?.get(CALL_TOOL_METHOD);
|
|
350
|
-
if (handlers === undefined || original === undefined)
|
|
358
|
+
if (handlers === undefined || original === undefined) {
|
|
359
|
+
log(SDK_SHAPE_MISSING, {
|
|
360
|
+
internal: handlers === undefined ? '_requestHandlers' : CALL_TOOL_METHOD,
|
|
361
|
+
lost: 'unadvertised-tool help',
|
|
362
|
+
});
|
|
351
363
|
return;
|
|
364
|
+
}
|
|
352
365
|
handlers.set(CALL_TOOL_METHOD, async (request, extra) => {
|
|
353
366
|
const name = toolNameOf(request);
|
|
354
367
|
const help = name === undefined ? undefined : unadvertisedToolHelp(name, advertised, known);
|
|
@@ -439,7 +452,16 @@ tools = TOOLS) {
|
|
|
439
452
|
.map((tool) => [tool.name, JSON.stringify(tool.example)])),
|
|
440
453
|
// Every tool in the table, not just the advertised ones: reticle_run dispatches to the full
|
|
441
454
|
// table, so an unadvertised tool reached through the hatch must be just as strict.
|
|
442
|
-
|
|
455
|
+
//
|
|
456
|
+
// ...and the advertised list on top of it, because the two META-tools are not in the table — they
|
|
457
|
+
// are composed onto the surface — so they had no entry, and no entry means no declared keys means
|
|
458
|
+
// every key allowed. `reticle_tools` took a misnamed argument, dropped it, and answered with the
|
|
459
|
+
// whole catalogue: a well-formed answer to a question nobody asked, on the one tool whose entire
|
|
460
|
+
// job is telling an agent what the surface is.
|
|
461
|
+
new Map([...tools, ...advertised].map((tool) => [
|
|
462
|
+
tool.name,
|
|
463
|
+
new Set(Object.keys(tool.inputSchema)),
|
|
464
|
+
])));
|
|
443
465
|
for (const tool of advertised) {
|
|
444
466
|
// Output schemas are now the largest slice of the per-request tax (55.6% of the hybrid payload
|
|
445
467
|
// after the predicate fix) and we are the only one of the three MCP servers that sends them.
|
|
@@ -18,11 +18,31 @@ import { SHARED_PARAM_GUIDANCE } from './shared-params.js';
|
|
|
18
18
|
* So the lead is state-dependent. A project that has never had an app connect is told the one thing
|
|
19
19
|
* it needs; a project that has is not nagged about a step it already took. The verdict discipline
|
|
20
20
|
* and the feedback ask are constant, because they matter in both states.
|
|
21
|
+
*
|
|
22
|
+
* The stopping rule is here for a measured reason. On a HEALTHY app in the competitor benchmark,
|
|
23
|
+
* this agent spent 22 turns and roughly 3.5x the cheapest competitor's tokens confirming that
|
|
24
|
+
* nothing was wrong — and it was not payload, which was the smallest of its five runs. It was
|
|
25
|
+
* turns: three navigations, four queries, a console read, a network read and an assert, on a page
|
|
26
|
+
* whose first verdict had already come back "yes" over a clean capture. Nothing anywhere told it
|
|
27
|
+
* when it was finished. Every other instruction here pushes toward MORE checking, which is right
|
|
28
|
+
* when something is broken and is the whole bill when nothing is.
|
|
29
|
+
*
|
|
30
|
+
* The diagnose-from-source rule has the same provenance and a bigger number behind it. Measured on
|
|
31
|
+
* a fix-and-verify benchmark, split at the call that writes the fix: this agent spent 14 and 18
|
|
32
|
+
* calls BEFORE its first edit where chrome-devtools-mcp spent 8 and 9 — and the sequences say why.
|
|
33
|
+
* It drove the app to understand it (snapshot, act, wait_for, snapshot, act, act_and_wait) and only
|
|
34
|
+
* then opened a file, while the competitor read six files and then looked once. Per-turn cost was
|
|
35
|
+
* IDENTICAL on the hardest cell (22,575 against 22,466), so the whole gap was turns, and most of
|
|
36
|
+
* those turns were spent asking a browser a question that only source can answer.
|
|
21
37
|
*/
|
|
22
38
|
/** The tools, and the rule that only two of them decide anything. Constant across both states. */
|
|
23
|
-
const VERDICT_DISCIPLINE = `Reticle verifies a running web app from the inside: look (reticle_snapshot / reticle_query), act and prove in one hop (reticle_act_and_wait), observe (reticle_state / reticle_network / reticle_console), assert (reticle_assert). Verify a user-facing change against the real app before you call it done, and never weaken a check to make it pass.
|
|
39
|
+
const VERDICT_DISCIPLINE = `Reticle verifies a running web app from the inside: go (reticle_navigate), look (reticle_snapshot / reticle_query), act and prove in one hop (reticle_act_and_wait), observe (reticle_observe / reticle_state / reticle_network / reticle_console), assert (reticle_assert). Verify a user-facing change against the real app before you call it done, and never weaken a check to make it pass.
|
|
40
|
+
|
|
41
|
+
Only reticle_act_and_wait and reticle_assert produce a verdict. reticle_act and everything else move or read the app and prove nothing, so a drive that ends without one of those two has no result however many tools it used. Prefer reticle_act_and_wait({ ref, action, until }) — it names the expected consequence BEFORE the action, which is the difference between a check and a rationalisation. Only verified:"yes" is a pass — "unknown" means Reticle could not tell what happened, "no-fault" means nothing was declared to prove. Report either as not proved.
|
|
24
42
|
|
|
25
|
-
|
|
43
|
+
A "yes" over a clean capture IS the answer — re-reading the page after one finds the same state. When every consequence you set out to check has one, stop.
|
|
44
|
+
|
|
45
|
+
DIAGNOSING a bug? Read the source first — Reticle proves what the app DOES, not why. Drive to CONFIRM a fix, not to find one.`;
|
|
26
46
|
/**
|
|
27
47
|
* The first move, for a project no app has ever connected to.
|
|
28
48
|
*
|
|
@@ -35,6 +55,28 @@ Only reticle_act_and_wait and reticle_assert produce a verdict. reticle_act and
|
|
|
35
55
|
const FIRST_MOVE = `FIRST: no app has ever connected to Reticle in this project, so nothing here can be verified yet. Having these tools is not the same as being set up — the tools reach a daemon, and the daemon has nothing to look at until the app itself is instrumented.
|
|
36
56
|
|
|
37
57
|
Fix that before anything else: run \`npx @reticlehq/server init\` in the project, then RESTART the dev server (a plugin added to a config the running server already read is not in the bundle), then load the app in a browser. Confirm with reticle_sessions — a session listed there is the proof, and until one appears no other tool can tell you anything about this app.`;
|
|
58
|
+
/**
|
|
59
|
+
* When to reach for the tools that are advertised but never explained, and how to reach the two
|
|
60
|
+
* that are explained but not advertised. Both halves of the same defect.
|
|
61
|
+
*
|
|
62
|
+
* An advertised tool arrives as a name and one line of description. That is enough to USE and not
|
|
63
|
+
* enough to CHOOSE, and `reticle_observe` is the case that proves it costs something: the
|
|
64
|
+
* measurement recorded on TOOL_SURFACE.VERIFY is that dropping the observation tools TRIPLED false
|
|
65
|
+
* alarms, because the model stops observing and reaches for the verdict without the evidence. So
|
|
66
|
+
* observe gets a reason, not a listing.
|
|
67
|
+
*
|
|
68
|
+
* The other direction: `reticle_context` and `reticle_intent` sit on the EXTENDED surface, so they
|
|
69
|
+
* are not in the list an agent is handed. Naming them without their call shape sent an agent at a
|
|
70
|
+
* tool it could not call, which is worse than silence — it teaches distrust of everything else
|
|
71
|
+
* here. They are now written the way the skill writes them, as the `reticle_run` call that works.
|
|
72
|
+
*
|
|
73
|
+
* Only in the connected state, and that is not a budget trick: an agent whose app has never
|
|
74
|
+
* connected has nothing to observe, no run to carry forward and nothing to declare an intent
|
|
75
|
+
* against. Its one instruction is the first move, and adding a second would compete with it.
|
|
76
|
+
*/
|
|
77
|
+
const REACH_FOR = `reticle_observe is the evidence channel: everything the page did since a cursor, in one read. Take it BEFORE you call a build broken — measured, stripping the observation tools TRIPLED false alarms, because an agent that stops observing reaches for the verdict without the evidence. reticle_wait_for settles a page that is changing without you; reticle_inspect maps an element to its source file:line, which turns a finding into an edit; reticle_session {action:"yield"} hands the tab back to the human.
|
|
78
|
+
|
|
79
|
+
Everything else is one hop: reticle_tools lists it, reticle_run calls it. Two to know early: reticle_run({ tool: "reticle_context", args: {} }) returns what this run already established, after a compaction or handover, and reticle_run({ tool: "reticle_intent", args: { action: "declare", ... } }) declares what a change was MEANT to do, so the verdict has something to check against other than itself.`;
|
|
38
80
|
/** The feedback ask. Constant: an agent that cannot get set up is the report we most need. */
|
|
39
81
|
const FEEDBACK_ASK = `Feedback is first-class here, not a courtesy. Reticle is built FOR agents, so your experience is the only signal that decides what gets built or fixed — and it disappears when your context does. Send it with \`reticle_feedback\` the moment you notice, then carry on with your task:
|
|
40
82
|
- Reticle broke, lied, or could not see something you needed (\`bug\` / \`gap\` / \`ambiguity\`).
|
|
@@ -50,7 +92,7 @@ Nothing is too minor, and a report costs one call. If the tools are unreachable
|
|
|
50
92
|
*/
|
|
51
93
|
export function buildServerInstructions(state) {
|
|
52
94
|
const base = state.previouslyConnected
|
|
53
|
-
? `${VERDICT_DISCIPLINE}\n\n${FEEDBACK_ASK}`
|
|
95
|
+
? `${VERDICT_DISCIPLINE}\n\n${REACH_FOR}\n\n${FEEDBACK_ASK}`
|
|
54
96
|
: `${FIRST_MOVE}\n\n${VERDICT_DISCIPLINE}\n\n${FEEDBACK_ASK}`;
|
|
55
97
|
return `${base}\n\n${SHARED_PARAM_GUIDANCE}`;
|
|
56
98
|
}
|
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
* its own confidently-wrong answer, and a proxy that has never seen one has nothing honest to serve —
|
|
16
16
|
* so it says nothing rather than inventing a list the agent would then call into.
|
|
17
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* Is this client line a request for the tool list?
|
|
20
|
+
*
|
|
21
|
+
* Lives here because this module already owns the method name and the tolerant parse. The caller is
|
|
22
|
+
* the proxy's stdin reader, which records the arrival as the one observable fact behind `reticle
|
|
23
|
+
* status`'s "registered, never enumerated" state — see attach-memory.ts.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isToolsListRequest(line: string): boolean;
|
|
18
26
|
export declare class ToolCatalogCache {
|
|
19
27
|
#private;
|
|
20
28
|
/** Watch a line coming BACK from the daemon; remember it if it is a tool catalog. */
|
|
@@ -25,6 +25,16 @@ function parse(line) {
|
|
|
25
25
|
return null;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Is this client line a request for the tool list?
|
|
30
|
+
*
|
|
31
|
+
* Lives here because this module already owns the method name and the tolerant parse. The caller is
|
|
32
|
+
* the proxy's stdin reader, which records the arrival as the one observable fact behind `reticle
|
|
33
|
+
* status`'s "registered, never enumerated" state — see attach-memory.ts.
|
|
34
|
+
*/
|
|
35
|
+
export function isToolsListRequest(line) {
|
|
36
|
+
return parse(line)?.method === LIST_METHOD;
|
|
37
|
+
}
|
|
28
38
|
export class ToolCatalogCache {
|
|
29
39
|
#tools;
|
|
30
40
|
/** Watch a line coming BACK from the daemon; remember it if it is a tool catalog. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type FlowStep } from '@reticlehq/core';
|
|
2
|
-
import {
|
|
2
|
+
import type { LocatedGap } from './self-instrument.js';
|
|
3
3
|
/**
|
|
4
4
|
* Bridge domain gaps → LOCATED instrumentation gaps. A flow that asserts no consequence should emit one —
|
|
5
5
|
* and the flow's own step anchors carry the source file:line the babel/next plugin stamped, so we know
|
|
@@ -12,7 +12,7 @@ export declare function lastSource(steps: readonly FlowStep[]): {
|
|
|
12
12
|
line: number;
|
|
13
13
|
} | undefined;
|
|
14
14
|
/**
|
|
15
|
-
* Turn unasserted flows into
|
|
16
|
-
* source are skipped (no location to propose). The suggested signal name is `<flow>:done`.
|
|
15
|
+
* Turn unasserted flows into no-signal-on-mutation gaps at each flow's source location. Flows with
|
|
16
|
+
* no stamped source are skipped (no location to propose). The suggested signal name is `<flow>:done`.
|
|
17
17
|
*/
|
|
18
|
-
export declare function instrumentationGapsForFlows(unassertedFlows: readonly string[], flowStepsByName: ReadonlyMap<string, readonly FlowStep[]>):
|
|
18
|
+
export declare function instrumentationGapsForFlows(unassertedFlows: readonly string[], flowStepsByName: ReadonlyMap<string, readonly FlowStep[]>): LocatedGap[];
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { AnchorKind } from '@reticlehq/core';
|
|
2
|
-
import { InstrumentationGapKind } from './self-instrument.js';
|
|
1
|
+
import { AnchorKind, InstrumentationGapKind } from '@reticlehq/core';
|
|
3
2
|
/**
|
|
4
3
|
* Bridge domain gaps → LOCATED instrumentation gaps. A flow that asserts no consequence should emit one —
|
|
5
4
|
* and the flow's own step anchors carry the source file:line the babel/next plugin stamped, so we know
|
|
@@ -22,8 +21,8 @@ export function lastSource(steps) {
|
|
|
22
21
|
return found;
|
|
23
22
|
}
|
|
24
23
|
/**
|
|
25
|
-
* Turn unasserted flows into
|
|
26
|
-
* source are skipped (no location to propose). The suggested signal name is `<flow>:done`.
|
|
24
|
+
* Turn unasserted flows into no-signal-on-mutation gaps at each flow's source location. Flows with
|
|
25
|
+
* no stamped source are skipped (no location to propose). The suggested signal name is `<flow>:done`.
|
|
27
26
|
*/
|
|
28
27
|
export function instrumentationGapsForFlows(unassertedFlows, flowStepsByName) {
|
|
29
28
|
const gaps = [];
|
|
@@ -33,7 +32,7 @@ export function instrumentationGapsForFlows(unassertedFlows, flowStepsByName) {
|
|
|
33
32
|
if (source === undefined)
|
|
34
33
|
continue;
|
|
35
34
|
gaps.push({
|
|
36
|
-
kind: InstrumentationGapKind.
|
|
35
|
+
kind: InstrumentationGapKind.NO_SIGNAL_ON_MUTATION,
|
|
37
36
|
file: source.file,
|
|
38
37
|
line: source.line,
|
|
39
38
|
name: `${name}:done`,
|
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
* apply instrumentation proposals as concrete diffs, so the AGENT closes the gap and the app's
|
|
5
5
|
* observability compounds run over run — instead of a human being a prerequisite. Deterministic mapping
|
|
6
6
|
* from a located gap to the code to insert; pure and testable. Result-enrichment wiring is a later step.
|
|
7
|
+
*
|
|
8
|
+
* Kinds come from `@reticlehq/core`. A second enum here is how `reticle_domain` used to say
|
|
9
|
+
* `missing-signal` while honesty said `no-signal-on-mutation` for the same absence.
|
|
7
10
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export type InstrumentationGapKind = (typeof InstrumentationGapKind)[keyof typeof InstrumentationGapKind];
|
|
14
|
-
export interface InstrumentationGap {
|
|
15
|
-
kind: InstrumentationGapKind;
|
|
11
|
+
import { InstrumentationGapKind } from '@reticlehq/core';
|
|
12
|
+
/** The three core kinds a located proposal can close. Other kinds are verdict-side, not insert-side. */
|
|
13
|
+
export type LocatableGapKind = typeof InstrumentationGapKind.NO_SIGNAL_ON_MUTATION | typeof InstrumentationGapKind.NO_STORE_REGISTERED | typeof InstrumentationGapKind.MISSING_TESTID;
|
|
14
|
+
export interface LocatedGap {
|
|
15
|
+
kind: LocatableGapKind;
|
|
16
16
|
file: string;
|
|
17
17
|
line: number;
|
|
18
18
|
/** The name to instrument with — a signal name, store name, or testid. */
|
|
@@ -28,5 +28,5 @@ interface InstrumentationProposal {
|
|
|
28
28
|
rationale: string;
|
|
29
29
|
}
|
|
30
30
|
/** Turn located capability gaps into ready-to-apply instrumentation proposals. Deterministic. */
|
|
31
|
-
export declare function proposeInstrumentation(gaps: readonly
|
|
31
|
+
export declare function proposeInstrumentation(gaps: readonly LocatedGap[]): InstrumentationProposal[];
|
|
32
32
|
export {};
|
|
@@ -4,33 +4,39 @@
|
|
|
4
4
|
* apply instrumentation proposals as concrete diffs, so the AGENT closes the gap and the app's
|
|
5
5
|
* observability compounds run over run — instead of a human being a prerequisite. Deterministic mapping
|
|
6
6
|
* from a located gap to the code to insert; pure and testable. Result-enrichment wiring is a later step.
|
|
7
|
+
*
|
|
8
|
+
* Kinds come from `@reticlehq/core`. A second enum here is how `reticle_domain` used to say
|
|
9
|
+
* `missing-signal` while honesty said `no-signal-on-mutation` for the same absence.
|
|
7
10
|
*/
|
|
8
|
-
|
|
9
|
-
MISSING_SIGNAL: 'missing-signal',
|
|
10
|
-
UNREGISTERED_STORE: 'unregistered-store',
|
|
11
|
-
MISSING_TESTID: 'missing-testid',
|
|
12
|
-
};
|
|
11
|
+
import { InstrumentationGapKind } from '@reticlehq/core';
|
|
13
12
|
/** PascalCase a store name for a `useX.getState` hint (cart → Cart). */
|
|
14
13
|
function pascal(name) {
|
|
15
14
|
return 0 === name.length ? name : name[0]?.toUpperCase() + name.slice(1);
|
|
16
15
|
}
|
|
17
16
|
function insertFor(gap) {
|
|
18
17
|
switch (gap.kind) {
|
|
19
|
-
case InstrumentationGapKind.
|
|
18
|
+
case InstrumentationGapKind.NO_SIGNAL_ON_MUTATION:
|
|
20
19
|
return `reticle.signal('${gap.name}');`;
|
|
21
|
-
case InstrumentationGapKind.
|
|
20
|
+
case InstrumentationGapKind.NO_STORE_REGISTERED:
|
|
22
21
|
return `registerStore('${gap.name}', () => use${pascal(gap.name)}.getState());`;
|
|
23
22
|
case InstrumentationGapKind.MISSING_TESTID:
|
|
24
23
|
return `data-testid="${gap.name}"`;
|
|
25
24
|
}
|
|
26
25
|
}
|
|
27
26
|
function rationaleFor(gap) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
let base;
|
|
28
|
+
switch (gap.kind) {
|
|
29
|
+
case InstrumentationGapKind.NO_SIGNAL_ON_MUTATION:
|
|
30
|
+
base = `emit a consequence signal so verification proves the outcome, not just presence`;
|
|
31
|
+
break;
|
|
32
|
+
case InstrumentationGapKind.NO_STORE_REGISTERED:
|
|
33
|
+
base = `register the store so state assertions can read the source of truth`;
|
|
34
|
+
break;
|
|
35
|
+
case InstrumentationGapKind.MISSING_TESTID:
|
|
36
|
+
base = `add a stable testid so the anchor survives refactors`;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
return undefined === gap.context ? base : `${base} — ${gap.context}`;
|
|
34
40
|
}
|
|
35
41
|
/** Turn located capability gaps into ready-to-apply instrumentation proposals. Deterministic. */
|
|
36
42
|
export function proposeInstrumentation(gaps) {
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* The browser is injected via a `Launcher` so all lifecycle logic is testable without real Chromium;
|
|
10
10
|
* the thin Playwright adapter that satisfies these interfaces lives separately.
|
|
11
11
|
*/
|
|
12
|
-
/** The minimal page surface the pool drives. Real Playwright `Page` satisfies this. */
|
|
13
12
|
export interface PooledPage {
|
|
14
13
|
goto(url: string, opts?: {
|
|
15
14
|
timeoutMs?: number;
|
|
@@ -17,6 +16,12 @@ export interface PooledPage {
|
|
|
17
16
|
close(): Promise<void>;
|
|
18
17
|
/** Fires when THIS page's renderer crashes — lets the pool reclaim just this lease, not the fleet. */
|
|
19
18
|
onCrash(handler: () => void): void;
|
|
19
|
+
/**
|
|
20
|
+
* Fires for each console message the page logs. OPTIONAL: a fake that does not implement it makes
|
|
21
|
+
* the pool record nothing, which is the correct degradation — an absent dial address must read as
|
|
22
|
+
* "the page said nothing", never as "the page dialled correctly".
|
|
23
|
+
*/
|
|
24
|
+
onConsole?(handler: (text: string) => void): void;
|
|
20
25
|
}
|
|
21
26
|
/** An isolated browsing context (cookies/storage). Real Playwright `BrowserContext` satisfies this. */
|
|
22
27
|
export interface PooledContext {
|
|
@@ -114,6 +119,12 @@ export declare class BrowserPool {
|
|
|
114
119
|
signal?: AbortSignal;
|
|
115
120
|
sessionId?: string;
|
|
116
121
|
}): Promise<Lease>;
|
|
122
|
+
/**
|
|
123
|
+
* The websocket address this lease's page reported it could not reach, if it reported one.
|
|
124
|
+
*
|
|
125
|
+
* Undefined means the page said nothing — never that it dialled the right place.
|
|
126
|
+
*/
|
|
127
|
+
dialFailureUrl(sessionId: string): string | undefined;
|
|
117
128
|
/** Close every context and the browser. Pending waiters are rejected (the pool is terminal now). */
|
|
118
129
|
shutdown(): Promise<void>;
|
|
119
130
|
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* The browser is injected via a `Launcher` so all lifecycle logic is testable without real Chromium;
|
|
10
10
|
* the thin Playwright adapter that satisfies these interfaces lives separately.
|
|
11
11
|
*/
|
|
12
|
+
/** The minimal page surface the pool drives. Real Playwright `Page` satisfies this. */
|
|
13
|
+
import { unreachableUrlIn } from '@reticlehq/core';
|
|
12
14
|
/** Default lease time-to-live: a lease untouched for this long is presumed orphaned and reclaimed. */
|
|
13
15
|
export const DEFAULT_LEASE_TTL_MS = 5 * 60_000;
|
|
14
16
|
/** Default cap on how long a lease's initial navigation may take before it fails (frees the slot). */
|
|
@@ -147,6 +149,7 @@ export class BrowserPool {
|
|
|
147
149
|
await this.#waitForSlot(opts.signal);
|
|
148
150
|
const sessionId = opts.sessionId ?? this.#genId();
|
|
149
151
|
let context;
|
|
152
|
+
let pending;
|
|
150
153
|
try {
|
|
151
154
|
const browser = await this.#ensureBrowser();
|
|
152
155
|
context = await browser.newContext();
|
|
@@ -156,6 +159,19 @@ export class BrowserPool {
|
|
|
156
159
|
page.onCrash(() => {
|
|
157
160
|
void this.#release(sessionId);
|
|
158
161
|
});
|
|
162
|
+
// Listen BEFORE goto: the SDK's connect attempt races the navigation's own resolution, and a
|
|
163
|
+
// listener attached afterwards misses the first failures — the only ones logged before the
|
|
164
|
+
// retry backoff stretches out.
|
|
165
|
+
page.onConsole?.((text) => {
|
|
166
|
+
const dialled = unreachableUrlIn(text);
|
|
167
|
+
if (dialled === undefined)
|
|
168
|
+
return;
|
|
169
|
+
const active = this.#active.get(sessionId);
|
|
170
|
+
if (active !== undefined)
|
|
171
|
+
active.dialFailureUrl = dialled;
|
|
172
|
+
else
|
|
173
|
+
pending = dialled; // logged during goto, before the lease is registered below
|
|
174
|
+
});
|
|
159
175
|
await page.goto(url, { timeoutMs: this.#navTimeout });
|
|
160
176
|
// The browser can crash WHILE goto is resolving; #onCrash then clears #active and zeroes
|
|
161
177
|
// #occupied. Registering the lease now would resurrect a dead entry against a crashed browser with
|
|
@@ -163,7 +179,13 @@ export class BrowserPool {
|
|
|
163
179
|
// no longer the live browser, bail — the catch below closes the context and returns the slot.
|
|
164
180
|
if (this.#browser !== browser)
|
|
165
181
|
throw new Error('browser crashed during navigation');
|
|
166
|
-
this.#active.set(sessionId, {
|
|
182
|
+
this.#active.set(sessionId, {
|
|
183
|
+
context,
|
|
184
|
+
page,
|
|
185
|
+
url,
|
|
186
|
+
touchedAt: this.#now(),
|
|
187
|
+
...(pending === undefined ? {} : { dialFailureUrl: pending }),
|
|
188
|
+
});
|
|
167
189
|
return {
|
|
168
190
|
sessionId,
|
|
169
191
|
url,
|
|
@@ -179,6 +201,14 @@ export class BrowserPool {
|
|
|
179
201
|
throw err;
|
|
180
202
|
}
|
|
181
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* The websocket address this lease's page reported it could not reach, if it reported one.
|
|
206
|
+
*
|
|
207
|
+
* Undefined means the page said nothing — never that it dialled the right place.
|
|
208
|
+
*/
|
|
209
|
+
dialFailureUrl(sessionId) {
|
|
210
|
+
return this.#active.get(sessionId)?.dialFailureUrl;
|
|
211
|
+
}
|
|
182
212
|
/** Close every context and the browser. Pending waiters are rejected (the pool is terminal now). */
|
|
183
213
|
async shutdown() {
|
|
184
214
|
this.#closed = true; // set first so any woken waiter rejects instead of relaunching a browser
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Headless by default — the pool exists for fault-tolerant headless multi-agent testing.
|
|
7
7
|
*/
|
|
8
8
|
import { BrowserLaunchKind } from '@reticlehq/core';
|
|
9
|
+
import { chromiumLaunchOptions } from '../chromium-launch-options.js';
|
|
9
10
|
import { getSessionMetrics } from '../telemetry/session-metrics.js';
|
|
10
11
|
import { classifyConnectFailure } from '../telemetry/connect-failure.js';
|
|
11
12
|
import { chromiumInstallCommand, bundledPlaywrightVersion } from '../cli/chromium-hint.js';
|
|
@@ -40,6 +41,7 @@ function wrapBrowser(browser) {
|
|
|
40
41
|
goto: (url, opts) => page.goto(url, gotoOptions(opts?.timeoutMs)),
|
|
41
42
|
close: () => page.close(),
|
|
42
43
|
onCrash: (handler) => page.on('crash', handler),
|
|
44
|
+
onConsole: (handler) => page.on('console', (msg) => handler(msg.text())),
|
|
43
45
|
};
|
|
44
46
|
},
|
|
45
47
|
close: () => context.close(),
|
|
@@ -72,7 +74,7 @@ export function playwrightLauncher(opts = {}) {
|
|
|
72
74
|
// number that actually costs memory and the one that explains a slow machine.
|
|
73
75
|
const settle = getSessionMetrics().recordConnectAttempt(BrowserLaunchKind.POOLED);
|
|
74
76
|
try {
|
|
75
|
-
const browser = wrapBrowser(await chromium.launch(
|
|
77
|
+
const browser = wrapBrowser(await chromium.launch(chromiumLaunchOptions(headless)));
|
|
76
78
|
settle();
|
|
77
79
|
return browser;
|
|
78
80
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type ProjectCandidate, type ProjectRegistry } from '@reticlehq/core';
|
|
2
|
+
import type { ConfigDiscovery } from '../cli/config-discovery.js';
|
|
3
|
+
/**
|
|
4
|
+
* Which `.reticle/` a session's artifacts belong in.
|
|
5
|
+
*
|
|
6
|
+
* Everything Reticle persists — flows, the capability contract, baselines, capsules, the cross-run
|
|
7
|
+
* project memory — used to resolve against the DAEMON's own `process.cwd()`. That is a statement
|
|
8
|
+
* about where the daemon was launched and it was being read as a statement about which project is
|
|
9
|
+
* being verified. Those are not the same thing and are usually not even the same tree: a user-scoped
|
|
10
|
+
* MCP registration is the common case, and the editor that spawns it starts it wherever it likes.
|
|
11
|
+
*
|
|
12
|
+
* Three field shapes, one defect: `cwd=/` gave `ENOENT: mkdir '/.reticle'`; a daemon started in
|
|
13
|
+
* project A wrote project B's flow into A's checkout and reported success without naming the path;
|
|
14
|
+
* and `verify_change` could only answer "unknown" because no flow could be persisted for it to match
|
|
15
|
+
* against.
|
|
16
|
+
*
|
|
17
|
+
* ## Two sources, one rule
|
|
18
|
+
*
|
|
19
|
+
* Candidates arrive from config discovery (which walks out from the daemon's own directory) and from
|
|
20
|
+
* the user-level project registry (which `init` writes, and which reaches checkouts discovery cannot
|
|
21
|
+
* see). This function takes the merged list and does not know or care which source an entry came
|
|
22
|
+
* from — a privileged source would be a second rule, and two rules for one question is how the
|
|
23
|
+
* answers start disagreeing.
|
|
24
|
+
*
|
|
25
|
+
* ## Why matching on projectId, and not a new wire field
|
|
26
|
+
*
|
|
27
|
+
* HELLO already stamps `projectId`, and `.reticle.json` already declares the same id sitting next to
|
|
28
|
+
* the code it configures. The join is therefore available today, for every SDK already in the field.
|
|
29
|
+
* Adding a `root` to HELLO would move the wire contract, strand every older SDK on the fallback, and
|
|
30
|
+
* buy nothing this does not already give.
|
|
31
|
+
*
|
|
32
|
+
* ## Why it can decline to answer
|
|
33
|
+
*
|
|
34
|
+
* Every non-matching branch returns the daemon root and SAYS which branch it was. A wrong root is
|
|
35
|
+
* worse than no root — it writes a caller's evidence into a tree they never drove — so the ambiguous
|
|
36
|
+
* case refuses rather than picks, exactly as `discoverProjectConfigs` refuses to pick one config.
|
|
37
|
+
* The reason travels with the answer so callers can report the surprise instead of hiding it.
|
|
38
|
+
*/
|
|
39
|
+
/** Why the root below is the root. Travels with the answer so a caller can say what happened. */
|
|
40
|
+
export declare const ArtifactRootReason: {
|
|
41
|
+
/** Exactly one discovered config declares this session's project. The good case. */
|
|
42
|
+
readonly MATCHED_PROJECT: "matched-project";
|
|
43
|
+
/** The session declared no projectId — a pre-2.0 SDK. Nothing to match on. */
|
|
44
|
+
readonly NO_PROJECT_ID: "no-project-id";
|
|
45
|
+
/** The search ran and nothing it found declares this project. */
|
|
46
|
+
readonly NO_MATCH: "no-match";
|
|
47
|
+
/** Two or more checkouts declare this project. Refused rather than guessed. */
|
|
48
|
+
readonly AMBIGUOUS: "ambiguous";
|
|
49
|
+
};
|
|
50
|
+
export type ArtifactRootReason = (typeof ArtifactRootReason)[keyof typeof ArtifactRootReason];
|
|
51
|
+
export interface ArtifactRootQuery {
|
|
52
|
+
/** The connected session's HELLO projectId, when it sent one. */
|
|
53
|
+
projectId: string | undefined;
|
|
54
|
+
/** Every project this machine knows about. Supplied, not gathered here — this stays pure. */
|
|
55
|
+
candidates: readonly ProjectCandidate[];
|
|
56
|
+
/** Where artifacts go when the project cannot be identified. Already a `.reticle` path. */
|
|
57
|
+
daemonRoot: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ArtifactRoot {
|
|
60
|
+
/** Absolute path to the `.reticle` directory. Always present, on every branch. */
|
|
61
|
+
root: string;
|
|
62
|
+
reason: ArtifactRootReason;
|
|
63
|
+
/**
|
|
64
|
+
* The competing project directories, on `AMBIGUOUS` only. A caller that has to explain the refusal
|
|
65
|
+
* needs to name them; a caller that does not can ignore the field.
|
|
66
|
+
*/
|
|
67
|
+
candidates?: string[];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the artifact root for one session.
|
|
71
|
+
*
|
|
72
|
+
* Pure: no IO, no cwd, no clock. The filesystem walk belongs to `discoverProjectConfigs` and is
|
|
73
|
+
* passed in, which is what makes every branch below testable without a fixture tree.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveArtifactRoot(query: ArtifactRootQuery): ArtifactRoot;
|
|
76
|
+
/**
|
|
77
|
+
* Everything this machine knows about where projects live, from both sources.
|
|
78
|
+
*
|
|
79
|
+
* Discovery first, deliberately: it read a `.reticle.json` that exists RIGHT NOW, while the registry
|
|
80
|
+
* is a cache of something `init` saw once. Order only decides which duplicate survives dedupe, and
|
|
81
|
+
* the one confirmed a moment ago is the better survivor.
|
|
82
|
+
*/
|
|
83
|
+
export declare function projectCandidatesFrom(discovery: ConfigDiscovery, registry: ProjectRegistry): ProjectCandidate[];
|