@reticlehq/server 2.12.0 → 2.13.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 +67 -109
- package/dist/bridge/auth-failure-reason.d.ts +6 -2
- package/dist/bridge/auth-failure-reason.js +15 -8
- package/dist/bridge/bridge.d.ts +18 -1
- package/dist/bridge/bridge.js +247 -164
- package/dist/bridge/pairing-token.d.ts +5 -0
- package/dist/bridge/pairing-token.js +28 -0
- package/dist/bridge/protocol-skew.d.ts +37 -0
- package/dist/bridge/protocol-skew.js +45 -0
- package/dist/capsule/capsule-store.d.ts +6 -6
- package/dist/capsule/causal-summary.js +7 -2
- package/dist/cli/cli-doctor.js +31 -1
- package/dist/cli/cli-launch.d.ts +11 -1
- package/dist/cli/cli-launch.js +59 -10
- package/dist/cli/cli-parse.d.ts +15 -1
- package/dist/cli/cli-parse.js +212 -8
- package/dist/cli/cli-verify.d.ts +1 -0
- package/dist/cli/cli-verify.js +3 -2
- package/dist/cli/cloud-cli.js +428 -259
- package/dist/cli/cloud-keystore.d.ts +37 -0
- package/dist/cli/cloud-keystore.js +97 -0
- package/dist/cli/cloud-kit.d.ts +37 -0
- package/dist/cli/cloud-kit.js +121 -0
- package/dist/cli/cloud-login.d.ts +17 -0
- package/dist/cli/cloud-login.js +183 -0
- package/dist/cli/cloud-session.d.ts +48 -0
- package/dist/cli/cloud-session.js +53 -0
- package/dist/cli/config-discovery.js +1 -0
- package/dist/cli/daemon-start-options.d.ts +32 -0
- package/dist/cli/daemon-start-options.js +38 -0
- package/dist/cli/device-flow.d.ts +76 -0
- package/dist/cli/device-flow.js +56 -0
- package/dist/cli/doctor-rows.d.ts +9 -0
- package/dist/cli/doctor-rows.js +9 -0
- package/dist/cli/drive-attach.d.ts +13 -0
- package/dist/cli/drive-attach.js +18 -0
- package/dist/cli/drive-command.js +7 -3
- package/dist/cli/mcp-command.js +51 -20
- package/dist/cli/project-name.d.ts +30 -0
- package/dist/cli/project-name.js +44 -0
- package/dist/cli/status-next-action.d.ts +14 -0
- package/dist/cli/status-next-action.js +4 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +134 -80
- package/dist/cloud/cloud-config.d.ts +22 -1
- package/dist/cloud/cloud-config.js +64 -5
- package/dist/cloud/cloud-sync.d.ts +5 -5
- package/dist/cloud/cloud-sync.js +33 -2
- package/dist/cloud/sync-cycle.d.ts +110 -0
- package/dist/cloud/sync-cycle.js +270 -0
- package/dist/cloud/sync-daemon.d.ts +69 -0
- package/dist/cloud/sync-daemon.js +260 -0
- package/dist/cloud/sync-disk.d.ts +9 -0
- package/dist/cloud/sync-disk.js +191 -0
- package/dist/cloud/sync-hash.d.ts +2 -0
- package/dist/cloud/sync-hash.js +35 -0
- package/dist/crawl/crawl-tools.js +5 -3
- package/dist/crawl/crawl.d.ts +3 -0
- package/dist/crawl/crawl.js +5 -0
- package/dist/daemon/daemon-resolve.d.ts +43 -0
- package/dist/daemon/daemon-resolve.js +69 -0
- package/dist/daemon/dev-servers.d.ts +15 -0
- package/dist/daemon/dev-servers.js +68 -0
- package/dist/daemon/wake-decision.d.ts +32 -0
- package/dist/daemon/wake-decision.js +38 -0
- package/dist/domain/domain-tools.js +2 -2
- package/dist/events/contradictions.d.ts +18 -0
- package/dist/events/contradictions.js +134 -21
- package/dist/events/declared.d.ts +7 -4
- package/dist/events/declared.js +15 -6
- package/dist/events/echo-mismatch.js +64 -14
- package/dist/events/event-filters.d.ts +8 -0
- package/dist/events/event-filters.js +19 -2
- package/dist/events/predicate-eval.js +53 -5
- package/dist/events/predicate-precheck.d.ts +8 -0
- package/dist/events/predicate-precheck.js +41 -0
- package/dist/events/predicate-route.d.ts +14 -0
- package/dist/events/predicate-route.js +36 -8
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +37 -5
- package/dist/events/split-text-miss.d.ts +30 -0
- package/dist/events/split-text-miss.js +44 -0
- package/dist/flows/flake.d.ts +6 -6
- package/dist/flows/flow-memory-consult.d.ts +55 -0
- package/dist/flows/flow-memory-consult.js +38 -0
- package/dist/flows/flow-replay-run.d.ts +18 -7
- package/dist/flows/flow-replay-run.js +239 -22
- package/dist/flows/flow-replay.js +11 -2
- package/dist/flows/flow-step-runners.d.ts +8 -0
- package/dist/flows/flow-step-runners.js +8 -2
- package/dist/flows/flow-store-for-session.d.ts +31 -0
- package/dist/flows/flow-store-for-session.js +33 -0
- package/dist/flows/flow-tools.js +58 -32
- package/dist/flows/flows.d.ts +8 -0
- package/dist/flows/flows.js +468 -2
- package/dist/flows/heal-run.js +5 -2
- package/dist/flows/predicate-to-expect.d.ts +17 -12
- package/dist/flows/predicate-to-expect.js +30 -12
- package/dist/flows/recordings.d.ts +13 -1
- package/dist/flows/recordings.js +17 -2
- package/dist/flows/replay.d.ts +22 -2
- package/dist/flows/replay.js +28 -2
- package/dist/flows/verify-change-tools.js +5 -2
- package/dist/honesty/blind-spots.d.ts +55 -2
- package/dist/honesty/blind-spots.js +63 -3
- package/dist/honesty/honesty.d.ts +24 -0
- package/dist/honesty/honesty.js +25 -1
- package/dist/honesty/instrumentation-gaps.d.ts +9 -0
- package/dist/honesty/instrumentation-gaps.js +29 -2
- package/dist/honesty/verified.d.ts +2 -1
- package/dist/honesty/verified.js +16 -1
- package/dist/http-server.js +12 -0
- package/dist/impact/impact-recorder.d.ts +31 -8
- package/dist/impact/impact-recorder.js +104 -21
- package/dist/impact/impact-store.d.ts +15 -1
- package/dist/impact/impact-store.js +43 -2
- package/dist/index.js +113 -7
- package/dist/init/agent-rules.d.ts +13 -6
- package/dist/init/agent-rules.js +23 -12
- package/dist/init/astro-patch.d.ts +16 -0
- package/dist/init/astro-patch.js +28 -0
- package/dist/init/capabilities.d.ts +4 -1
- package/dist/init/capabilities.js +22 -2
- package/dist/init/confirm.d.ts +31 -7
- package/dist/init/confirm.js +48 -8
- package/dist/init/cra.d.ts +20 -4
- package/dist/init/cra.js +57 -17
- package/dist/init/csp-check.js +17 -2
- package/dist/init/csp-doctor.d.ts +8 -1
- package/dist/init/csp-doctor.js +8 -3
- package/dist/init/dev-script.d.ts +3 -1
- package/dist/init/dev-script.js +8 -5
- package/dist/init/format-generated.d.ts +18 -0
- package/dist/init/format-generated.js +39 -0
- package/dist/init/install-hint.d.ts +9 -0
- package/dist/init/install-hint.js +29 -0
- package/dist/init/mcp-clients.d.ts +7 -0
- package/dist/init/mcp-clients.js +7 -0
- package/dist/init/mcp.d.ts +1 -1
- package/dist/init/mcp.js +1 -1
- package/dist/init/node-io.js +11 -1
- package/dist/init/plan-framework.js +81 -45
- package/dist/init/plan.d.ts +20 -7
- package/dist/init/plan.js +20 -45
- package/dist/init/preflight.d.ts +28 -0
- package/dist/init/preflight.js +38 -0
- package/dist/init/run.d.ts +32 -9
- package/dist/init/run.js +141 -119
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/snippets.js +41 -25
- package/dist/init/vite-config.d.ts +1 -1
- package/dist/init/vite-config.js +39 -25
- package/dist/init/workspace-apps.d.ts +17 -10
- package/dist/init/workspace-apps.js +110 -0
- package/dist/input/network-mock-tools.js +3 -6
- package/dist/input/real-input.js +25 -5
- package/dist/input/scroll-tools.js +4 -6
- package/dist/input/viewport-tools.js +5 -4
- package/dist/intent/inline-intent.d.ts +10 -19
- package/dist/intent/inline-intent.js +91 -1
- package/dist/intent/intent-shard-store.d.ts +72 -0
- package/dist/intent/intent-shard-store.js +203 -0
- package/dist/intent/intent-shard.d.ts +411 -0
- package/dist/intent/intent-shard.js +176 -0
- package/dist/intent/intent-store.d.ts +12 -0
- package/dist/intent/intent-store.js +21 -0
- package/dist/intent/intent-subject.d.ts +45 -0
- package/dist/intent/intent-subject.js +177 -0
- package/dist/intent/intent-tools.js +65 -2
- package/dist/intent/open-intents.d.ts +21 -0
- package/dist/intent/open-intents.js +15 -0
- package/dist/license/license-env.d.ts +29 -0
- package/dist/license/license-env.js +148 -0
- package/dist/loopback-agent.d.ts +31 -0
- package/dist/loopback-agent.js +57 -0
- package/dist/mcp/attach-memory.d.ts +18 -2
- package/dist/mcp/attach-memory.js +50 -7
- package/dist/mcp/client-identity.d.ts +31 -0
- package/dist/mcp/client-identity.js +51 -0
- package/dist/mcp/mcp-post-transport.d.ts +26 -0
- package/dist/mcp/mcp-post-transport.js +109 -0
- package/dist/mcp/mcp-proxy.d.ts +2 -1
- package/dist/mcp/mcp-proxy.js +52 -62
- package/dist/mcp/mcp.js +7 -1
- package/dist/mcp/proxy-backoff.d.ts +11 -0
- package/dist/mcp/proxy-backoff.js +13 -0
- package/dist/mcp/proxy-daemon-probe.d.ts +1 -0
- package/dist/mcp/proxy-daemon-probe.js +7 -1
- package/dist/mcp/proxy-handshake.d.ts +23 -0
- package/dist/mcp/proxy-handshake.js +31 -0
- package/dist/mcp/tool-catalog-cache.d.ts +8 -0
- package/dist/mcp/tool-catalog-cache.js +12 -0
- package/dist/memory/memory-tools.d.ts +2 -0
- package/dist/memory/memory-tools.js +82 -0
- package/dist/memory/project-memory.d.ts +34 -0
- package/dist/memory/project-memory.js +96 -0
- package/dist/oracles/propose-consequences.js +12 -3
- package/dist/pool/browser-pool.d.ts +63 -4
- package/dist/pool/browser-pool.js +113 -4
- package/dist/pool/playwright-launcher.js +9 -0
- package/dist/project/project-tools.js +3 -3
- package/dist/project/reticle-dir.d.ts +15 -2
- package/dist/project/reticle-dir.js +21 -4
- package/dist/runs/run-store.d.ts +10 -0
- package/dist/runs/run-store.js +10 -0
- package/dist/runs/verification-sync.d.ts +0 -11
- package/dist/runs/verification-sync.js +22 -1
- package/dist/runs/verify-server.d.ts +8 -1
- package/dist/runs/verify-server.js +14 -2
- package/dist/session/command-timeout.d.ts +5 -2
- package/dist/session/command-timeout.js +5 -4
- package/dist/session/lease-visibility.d.ts +67 -0
- package/dist/session/lease-visibility.js +76 -0
- package/dist/session/no-session-diagnosis.d.ts +23 -0
- package/dist/session/no-session-diagnosis.js +58 -17
- package/dist/session/no-session-next-action.d.ts +7 -0
- package/dist/session/no-session-next-action.js +42 -3
- package/dist/session/no-session-watch.d.ts +10 -6
- package/dist/session/no-session-watch.js +51 -19
- package/dist/session/pending-commands.d.ts +11 -1
- package/dist/session/pending-commands.js +12 -2
- package/dist/session/resolve-within.d.ts +34 -0
- package/dist/session/resolve-within.js +53 -0
- package/dist/session/session-info.d.ts +18 -0
- package/dist/session/session-info.js +11 -0
- package/dist/session/session-manager.d.ts +14 -0
- package/dist/session/session-manager.js +61 -3
- package/dist/session/session-mirror.d.ts +16 -0
- package/dist/session/session-mirror.js +22 -0
- package/dist/session/session-recommendation.d.ts +19 -5
- package/dist/session/session-recommendation.js +25 -9
- package/dist/session/session-successor.js +36 -1
- package/dist/session/session-tools.js +2 -2
- package/dist/session/session.d.ts +22 -2
- package/dist/session/session.js +87 -7
- package/dist/session/wall-clock.d.ts +12 -0
- package/dist/session/wall-clock.js +14 -0
- package/dist/setup/agent-configs.d.ts +96 -0
- package/dist/setup/agent-configs.js +333 -0
- package/dist/setup/agent-io.d.ts +9 -0
- package/dist/setup/agent-io.js +16 -0
- package/dist/setup/agent-writer.d.ts +51 -0
- package/dist/setup/agent-writer.js +115 -0
- package/dist/setup/approval-migration.d.ts +47 -0
- package/dist/setup/approval-migration.js +68 -0
- package/dist/setup/auto-approve.d.ts +85 -0
- package/dist/setup/auto-approve.js +150 -0
- package/dist/setup/bridge-port.d.ts +15 -0
- package/dist/setup/bridge-port.js +24 -0
- package/dist/setup/desktop-shape.d.ts +57 -0
- package/dist/setup/desktop-shape.js +63 -0
- package/dist/setup/dev-server-wait.d.ts +60 -0
- package/dist/setup/dev-server-wait.js +65 -0
- package/dist/setup/drive-agent.d.ts +71 -0
- package/dist/setup/drive-agent.js +171 -0
- package/dist/setup/drive-plan.d.ts +68 -0
- package/dist/setup/drive-plan.js +130 -0
- package/dist/setup/ensure-daemon.d.ts +29 -0
- package/dist/setup/ensure-daemon.js +58 -0
- package/dist/setup/init-runtime.d.ts +47 -0
- package/dist/setup/init-runtime.js +160 -0
- package/dist/setup/interrupt.d.ts +25 -0
- package/dist/setup/interrupt.js +40 -0
- package/dist/setup/license-key.d.ts +39 -0
- package/dist/setup/license-key.js +72 -0
- package/dist/setup/listeners.d.ts +37 -0
- package/dist/setup/listeners.js +85 -0
- package/dist/setup/node-effects.d.ts +60 -0
- package/dist/setup/node-effects.js +210 -0
- package/dist/setup/page-probe.d.ts +39 -0
- package/dist/setup/page-probe.js +49 -0
- package/dist/setup/relaunch.d.ts +36 -0
- package/dist/setup/relaunch.js +57 -0
- package/dist/setup/remaining-steps.d.ts +19 -0
- package/dist/setup/remaining-steps.js +31 -0
- package/dist/setup/run-setup.d.ts +99 -0
- package/dist/setup/run-setup.js +187 -0
- package/dist/setup/session-pick.d.ts +47 -0
- package/dist/setup/session-pick.js +51 -0
- package/dist/setup/setup-command.d.ts +45 -0
- package/dist/setup/setup-command.js +199 -0
- package/dist/setup/setup-options.d.ts +74 -0
- package/dist/setup/setup-options.js +67 -0
- package/dist/setup/transcripts.d.ts +16 -0
- package/dist/setup/transcripts.js +43 -0
- package/dist/status-payload.d.ts +14 -1
- package/dist/status-payload.js +23 -1
- package/dist/telemetry/daemon-telemetry.d.ts +8 -1
- package/dist/telemetry/daemon-telemetry.js +52 -5
- package/dist/telemetry/error-class.js +1 -1
- package/dist/telemetry/feedback-context.d.ts +9 -1
- package/dist/telemetry/feedback-context.js +60 -29
- package/dist/telemetry/feedback-local.d.ts +15 -0
- package/dist/telemetry/feedback-local.js +56 -0
- package/dist/telemetry/feedback.d.ts +11 -0
- package/dist/telemetry/feedback.js +19 -6
- package/dist/telemetry/init-telemetry.d.ts +2 -0
- package/dist/telemetry/init-telemetry.js +2 -0
- package/dist/telemetry/install-source.d.ts +14 -0
- package/dist/telemetry/install-source.js +30 -0
- package/dist/telemetry/license-activation.d.ts +7 -1
- package/dist/telemetry/license-activation.js +9 -5
- package/dist/telemetry/mcp-connection.js +7 -0
- package/dist/telemetry/project-profile.d.ts +12 -1
- package/dist/telemetry/project-profile.js +11 -6
- package/dist/telemetry/proxy-telemetry.d.ts +3 -0
- package/dist/telemetry/proxy-telemetry.js +21 -0
- package/dist/telemetry/session-metrics.d.ts +12 -0
- package/dist/telemetry/session-metrics.js +35 -1
- package/dist/telemetry/telemetry.js +17 -2
- package/dist/telemetry/tool-refused.d.ts +3 -1
- package/dist/telemetry/tool-refused.js +34 -2
- package/dist/tools/act-capsule.d.ts +10 -0
- package/dist/tools/act-capsule.js +8 -2
- package/dist/tools/act-danger.d.ts +2 -1
- package/dist/tools/act-danger.js +11 -3
- package/dist/tools/act-observation.d.ts +41 -0
- package/dist/tools/act-observation.js +36 -0
- package/dist/tools/act-preflight.d.ts +20 -0
- package/dist/tools/act-preflight.js +98 -0
- package/dist/tools/act-sequence-retry.d.ts +45 -0
- package/dist/tools/act-sequence-retry.js +58 -0
- package/dist/tools/act-sequence-tool.d.ts +13 -0
- package/dist/tools/act-sequence-tool.js +135 -0
- package/dist/tools/act-target.d.ts +14 -0
- package/dist/tools/act-target.js +47 -0
- package/dist/tools/act-tools.d.ts +21 -1
- package/dist/tools/act-tools.js +100 -213
- package/dist/tools/assert-verdict.js +22 -16
- package/dist/tools/browser-tools.js +6 -2
- package/dist/tools/dynamic-tools.js +24 -5
- package/dist/tools/error-recovery.d.ts +1 -0
- package/dist/tools/error-recovery.js +8 -0
- package/dist/tools/invoke-tool.js +59 -9
- package/dist/tools/lease-tools.d.ts +30 -4
- package/dist/tools/lease-tools.js +169 -5
- package/dist/tools/merged-name-redirect.d.ts +13 -0
- package/dist/tools/merged-name-redirect.js +22 -0
- package/dist/tools/numeric-bounds.d.ts +100 -0
- package/dist/tools/numeric-bounds.js +125 -0
- package/dist/tools/observe-tools.js +47 -40
- package/dist/tools/read-tools.js +28 -7
- package/dist/tools/real-input-attempt.d.ts +10 -0
- package/dist/tools/real-input-attempt.js +59 -1
- package/dist/tools/reconcile-tools.js +4 -4
- package/dist/tools/tool-kit.d.ts +12 -0
- package/dist/tools/tool-kit.js +6 -1
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tools-helpers.d.ts +19 -0
- package/dist/tools/tools-helpers.js +46 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +56 -8
- package/dist/update/reticle-deps.d.ts +9 -0
- package/dist/update/reticle-deps.js +27 -0
- package/dist/update/sdk-sync.d.ts +1 -2
- package/dist/update/sdk-sync.js +1 -21
- package/dist/update/update-nudge.d.ts +18 -0
- package/dist/update/update-nudge.js +20 -0
- package/dist/version/peer-announce.js +4 -2
- package/dist/version/sdk-fix.d.ts +47 -0
- package/dist/version/sdk-fix.js +127 -0
- package/dist/version/version-nudge.d.ts +14 -0
- package/dist/version/version-nudge.js +21 -0
- package/dist/version/version-skew.d.ts +38 -3
- package/dist/version/version-skew.js +83 -7
- package/dist/visual/visual-store.d.ts +6 -6
- package/dist/visual/visual-store.js +18 -16
- package/dist/visual/visual-tools.js +57 -12
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/serve.mdx +4 -2
- package/docs/cli.mdx +7 -3
- package/docs/desktop.mdx +1 -1
- package/docs/docs.json +1 -1
- package/docs/getting-started.md +19 -3
- package/docs/index.mdx +9 -6
- package/docs/install-agentic.mdx +38 -12
- package/docs/install-manual.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/skill-file.mdx +3 -3
- package/docs/telemetry-contract.md +108 -4
- package/docs/tools-act-sequence.mdx +16 -0
- package/docs/usage.md +1 -1
- package/package.json +4 -3
- package/dist/init/cursor.d.ts +0 -24
- package/dist/init/cursor.js +0 -80
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filesystem half of a sync cycle: what `.reticle/` looks like to the protocol.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from `sync-cycle.ts` on purpose. The cycle is the interesting part — what to send, what
|
|
5
|
+
* to skip, what to do when the network is down — and it stays provable only because it never touches
|
|
6
|
+
* a disk. This is the boring part, and boring is what it should be: read a file, tolerate its
|
|
7
|
+
* absence, never throw.
|
|
8
|
+
*
|
|
9
|
+
* EVERY READ IS FORGIVING. A half-written impact.json from a process that died mid-flush must cost
|
|
10
|
+
* one unsynced record, not a crashed sync. There is no state here worth defending against a parse
|
|
11
|
+
* error — the file will be rewritten on the next tool call anyway.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from 'node:fs';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
16
|
+
import { subjectFor } from '../intent/intent-subject.js';
|
|
17
|
+
const JSON_SUFFIX = '.json';
|
|
18
|
+
/** Read + parse, or undefined for absent/unreadable/malformed. Never throws. */
|
|
19
|
+
function readJson(path) {
|
|
20
|
+
try {
|
|
21
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Write atomically. tmp + rename, because two daemons can serve one repo at once and a half-written
|
|
29
|
+
* cursor file is one the next read discards — which would silently re-pull the whole decision
|
|
30
|
+
* history, or worse, skip it.
|
|
31
|
+
*/
|
|
32
|
+
function writeJson(path, value) {
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
35
|
+
const tmp = `${path}.${String(process.pid)}.tmp`;
|
|
36
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
37
|
+
renameSync(tmp, path);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Bookkeeping that cannot be written must never break a sync. The next cycle re-derives it; the
|
|
41
|
+
// worst case is one redundant upload, which the server dedupes anyway.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Every `*.json` directly inside a directory, parsed. Absent directory reads as empty. */
|
|
45
|
+
function readJsonDir(dir) {
|
|
46
|
+
try {
|
|
47
|
+
if (!existsSync(dir))
|
|
48
|
+
return [];
|
|
49
|
+
return readdirSync(dir)
|
|
50
|
+
.filter((f) => f.endsWith(JSON_SUFFIX))
|
|
51
|
+
.map((f) => readJson(join(dir, f)))
|
|
52
|
+
.filter((v) => v !== undefined);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Flows live one directory deeper, under the app's own project id. */
|
|
59
|
+
function readFlows(root) {
|
|
60
|
+
const dir = join(root, ReticleDir.FLOWS_SUBDIR);
|
|
61
|
+
try {
|
|
62
|
+
if (!existsSync(dir))
|
|
63
|
+
return [];
|
|
64
|
+
return readdirSync(dir).flatMap((scope) => readJsonDir(join(dir, scope)));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const DERIVED_FILE = {
|
|
71
|
+
impact: ReticleDir.IMPACT_FILE,
|
|
72
|
+
flake: ReticleDir.FLAKE_FILE,
|
|
73
|
+
intent: ReticleDir.INTENT_FILE,
|
|
74
|
+
};
|
|
75
|
+
/** The directory the sharded intent store writes into, beside the legacy flat file. */
|
|
76
|
+
const INTENT_SUBDIR = 'intent';
|
|
77
|
+
const INTENT_INDEX = 'index.json';
|
|
78
|
+
/**
|
|
79
|
+
* Every intent this project holds, flat file and shards merged.
|
|
80
|
+
*
|
|
81
|
+
* The store was split into one file per subject so that writing one record stops rewriting all of
|
|
82
|
+
* them. Sync was not told, and kept reading `intent.json` alone — which the migration deliberately
|
|
83
|
+
* does not delete. The result was the worst shape a sync bug can take: not an error, but a corpus
|
|
84
|
+
* that looked healthy on the dashboard and had silently stopped moving, because every intent
|
|
85
|
+
* captured after the migration stayed on the engineer's laptop.
|
|
86
|
+
*
|
|
87
|
+
* Shards WIN on a collision, matching the store's own rule: an id present in both has been migrated
|
|
88
|
+
* and possibly edited since, and letting the flat copy overwrite that would revert the edit on every
|
|
89
|
+
* cycle. `index.json` is derived from the shards and is skipped — sending it would duplicate every
|
|
90
|
+
* record as a summary of itself.
|
|
91
|
+
*
|
|
92
|
+
* Returns undefined when there is no memory at all, so a project with nothing to say sends nothing
|
|
93
|
+
* rather than an empty envelope the server has to store.
|
|
94
|
+
*/
|
|
95
|
+
function readIntent(reticleRoot) {
|
|
96
|
+
const legacy = readJson(join(reticleRoot, ReticleDir.INTENT_FILE));
|
|
97
|
+
const merged = {
|
|
98
|
+
...(legacy?.intents ?? {}),
|
|
99
|
+
};
|
|
100
|
+
let shards = 0;
|
|
101
|
+
for (const file of safeReaddir(join(reticleRoot, INTENT_SUBDIR))) {
|
|
102
|
+
if (!file.endsWith(JSON_SUFFIX) || INTENT_INDEX === file)
|
|
103
|
+
continue;
|
|
104
|
+
const shard = readJson(join(reticleRoot, INTENT_SUBDIR, file));
|
|
105
|
+
// A hand-edited or half-written shard costs one subject, never the whole sync.
|
|
106
|
+
if (shard?.intents === undefined)
|
|
107
|
+
continue;
|
|
108
|
+
shards++;
|
|
109
|
+
for (const [id, record] of Object.entries(shard.intents))
|
|
110
|
+
merged[id] = record;
|
|
111
|
+
}
|
|
112
|
+
if (legacy === undefined && 0 === shards)
|
|
113
|
+
return undefined;
|
|
114
|
+
/*
|
|
115
|
+
* Stamp the SUBJECT before sending, on anything that does not already carry one.
|
|
116
|
+
*
|
|
117
|
+
* The subject ladder lives here, in the engine, and knows how to read a flow, a route and a
|
|
118
|
+
* binding predicate. The server had no access to it and had reimplemented a far weaker version —
|
|
119
|
+
* flow-or-nothing — so records this ladder files correctly arrived at the dashboard as
|
|
120
|
+
* `unsorted`. Measured on the real corpus: 105 of 163 records the engine can place were landing
|
|
121
|
+
* in the bucket of last resort, which is what made a coverage map read as one pile with six
|
|
122
|
+
* labels.
|
|
123
|
+
*
|
|
124
|
+
* Computed HERE rather than duplicated there, because two rules for one question is how the
|
|
125
|
+
* answers start disagreeing — and the one with the evidence should be the one that decides.
|
|
126
|
+
* Records that already name a subject keep it: an agent's explicit choice outranks inference.
|
|
127
|
+
*/
|
|
128
|
+
const placed = Object.fromEntries(Object.entries(merged).map(([id, record]) => {
|
|
129
|
+
const held = record;
|
|
130
|
+
if ('string' === typeof held.subject && held.subject.length > 0)
|
|
131
|
+
return [id, record];
|
|
132
|
+
const subject = subjectFor({
|
|
133
|
+
surface: held.surface,
|
|
134
|
+
binding: held.binding,
|
|
135
|
+
});
|
|
136
|
+
return [id, { ...held, subject }];
|
|
137
|
+
}));
|
|
138
|
+
return { version: 1, intents: placed };
|
|
139
|
+
}
|
|
140
|
+
/** Directory listing that tolerates absence — an unmigrated project has no `intent/`. */
|
|
141
|
+
function safeReaddir(dir) {
|
|
142
|
+
try {
|
|
143
|
+
return readdirSync(dir);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** What the cycle reads, backed by a real `.reticle` directory. */
|
|
150
|
+
export function diskSource(reticleRoot) {
|
|
151
|
+
return {
|
|
152
|
+
runs: () => readJsonDir(join(reticleRoot, ReticleDir.RUNS_SUBDIR))
|
|
153
|
+
.map((payload) => {
|
|
154
|
+
const id = payload?.runId;
|
|
155
|
+
return 'string' === typeof id ? { runId: id, payload } : undefined;
|
|
156
|
+
})
|
|
157
|
+
// A run artifact with no id cannot be diffed against the server's list, so sending it would
|
|
158
|
+
// mean re-sending it every cycle forever. Dropped rather than uploaded repeatedly.
|
|
159
|
+
.filter((r) => r !== undefined),
|
|
160
|
+
flows: () => readFlows(reticleRoot),
|
|
161
|
+
// Intent is the only derived record that is no longer one file. See readIntent.
|
|
162
|
+
derived: (kind) => 'intent' === kind ? readIntent(reticleRoot) : readJson(join(reticleRoot, DERIVED_FILE[kind])),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** What the cycle writes back into `.reticle/`. */
|
|
166
|
+
export function diskSink(reticleRoot) {
|
|
167
|
+
return {
|
|
168
|
+
writeIssues: (issues) => {
|
|
169
|
+
// MERGED, not replaced. A pull returns only what changed since the cursor, so overwriting
|
|
170
|
+
// would drop every earlier decision the moment one new decision arrived.
|
|
171
|
+
const held = readCloudIssues(reticleRoot);
|
|
172
|
+
writeJson(join(reticleRoot, ReticleDir.ISSUES_FILE), {
|
|
173
|
+
triage: { ...held.triage, ...issues.triage },
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
writeState: (state) => {
|
|
177
|
+
writeJson(join(reticleRoot, ReticleDir.CLOUD_STATE_FILE), state);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** This machine's side of the conversation, or a blank one on first run. */
|
|
182
|
+
export function readCloudState(reticleRoot) {
|
|
183
|
+
const raw = readJson(join(reticleRoot, ReticleDir.CLOUD_STATE_FILE));
|
|
184
|
+
return 'object' === typeof raw && null !== raw ? raw : {};
|
|
185
|
+
}
|
|
186
|
+
/** The decisions pulled back from the dashboard, for the HUD and for the next run to read. */
|
|
187
|
+
export function readCloudIssues(reticleRoot) {
|
|
188
|
+
const raw = readJson(join(reticleRoot, ReticleDir.ISSUES_FILE));
|
|
189
|
+
const triage = 'object' === typeof raw && null !== raw ? raw.triage : undefined;
|
|
190
|
+
return { triage: 'object' === typeof triage && null !== triage ? triage : {} };
|
|
191
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content hash both halves of sync compare on.
|
|
3
|
+
*
|
|
4
|
+
* The server answers "here is a hash per record you have already sent me"; the machine hashes what
|
|
5
|
+
* it holds and sends only what differs. That only works if the two sides compute the SAME number
|
|
6
|
+
* from the same record, so this function is a CONTRACT, not a utility — it has to stay byte-for-byte
|
|
7
|
+
* equivalent to the server's, and it is separated out and tested against fixed vectors for exactly
|
|
8
|
+
* that reason.
|
|
9
|
+
*
|
|
10
|
+
* Keys are sorted before serialising because two structurally identical records must hash the same
|
|
11
|
+
* however they happened to be written out. Without that, a machine whose JSON key order differed
|
|
12
|
+
* from the server's would re-upload the same unchanged record on every cycle, forever, and the whole
|
|
13
|
+
* point of hashing — not paying for what has not moved — would be lost silently.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
/** How much of the digest is kept. Long enough that a collision is not a practical concern here. */
|
|
17
|
+
const HASH_LENGTH = 32;
|
|
18
|
+
/** Recursively sort object keys so serialisation is canonical. Arrays keep their order — it is data. */
|
|
19
|
+
function canonical(value) {
|
|
20
|
+
if (Array.isArray(value))
|
|
21
|
+
return value.map(canonical);
|
|
22
|
+
if (value !== null && 'object' === typeof value) {
|
|
23
|
+
return Object.fromEntries(Object.entries(value)
|
|
24
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
25
|
+
.map(([k, v]) => [k, canonical(v)]));
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
/** A stable content hash of one derived record. Must equal the server's for the same input. */
|
|
30
|
+
export function hashPayload(payload) {
|
|
31
|
+
return createHash('sha256')
|
|
32
|
+
.update(JSON.stringify(canonical(payload)))
|
|
33
|
+
.digest('hex')
|
|
34
|
+
.slice(0, HASH_LENGTH);
|
|
35
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ReticleTool } from '../tools/tool-names.js';
|
|
3
3
|
import { asNumber, asString } from '../tools/tools-helpers.js';
|
|
4
|
+
import { stepCountSchema, timeoutMsSchema } from '../tools/numeric-bounds.js';
|
|
4
5
|
import { crawl } from './crawl.js';
|
|
5
6
|
import { routeFromUrl, routesFromEvents } from '../project/learned-routes.js';
|
|
6
7
|
const nodeSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -14,9 +15,10 @@ export const CRAWL_TOOLS = [
|
|
|
14
15
|
name: ReticleTool.CRAWL,
|
|
15
16
|
description: 'Autonomously click every reachable interactive control (bounded by maxSteps, default 25) and report anomalies WITHOUT a script. Two classes: single-channel faults (console errors, failed requests ≥400, DEAD controls that dispatched but did nothing) and CONTRADICTIONS — two channels disagreeing about the same click, e.g. the UI advanced while its write failed, a success signal fired over a failed request, a write succeeded and nothing changed, the same write fired twice, or the UI moved on over an in-flight request. Contradictions are the false greens a human cannot see, because a human watches one channel (the screen) and it looks correct. DESTRUCTIVE — it really clicks (may navigate/mutate state); use reticle_explore first for a non-destructive list. Returns { interactiveFound, stepsRun, anomalies[{kind,ref,desc,detail}], counts, visited, truncated }.',
|
|
16
17
|
inputSchema: {
|
|
17
|
-
maxSteps:
|
|
18
|
-
|
|
19
|
-
.number
|
|
18
|
+
maxSteps: stepCountSchema
|
|
19
|
+
.optional()
|
|
20
|
+
.describe('Maximum number of controls to click. Default: 25.'),
|
|
21
|
+
settleMs: timeoutMsSchema
|
|
20
22
|
.optional()
|
|
21
23
|
.describe('Milliseconds to wait after each click for the app to react. Default: 500.'),
|
|
22
24
|
scope: z
|
package/dist/crawl/crawl.d.ts
CHANGED
|
@@ -13,6 +13,9 @@ export interface CrawlSession {
|
|
|
13
13
|
/** Which round of source edits is in force, so a finding drawn entirely from pre-edit evidence
|
|
14
14
|
* says so. Optional for the same reason `currentDocumentId` is. */
|
|
15
15
|
readonly currentEditEpoch?: number | undefined;
|
|
16
|
+
/** The page under test, so a third-party beacon cannot be reported against a control. Optional
|
|
17
|
+
* for the same reason `currentDocumentId` is. */
|
|
18
|
+
readonly url?: string | undefined;
|
|
16
19
|
/**
|
|
17
20
|
* Attribution window around each click. Optional so a caller can supply a minimal session, but a real
|
|
18
21
|
* session MUST provide it: without a window the click's own effects carry no actionId, and an
|
package/dist/crawl/crawl.js
CHANGED
|
@@ -216,6 +216,11 @@ export async function crawl(session, opts, sleep) {
|
|
|
216
216
|
for (const c of findContradictions(events, {
|
|
217
217
|
currentDocumentId: session.currentDocumentId,
|
|
218
218
|
currentEditEpoch: session.currentEditEpoch,
|
|
219
|
+
appOrigin: session.url,
|
|
220
|
+
// The crawl's window IS one control's click, so it can name the floor the consequence rules
|
|
221
|
+
// need — without it a crawl would report nothing about the UI moving, which is most of what a
|
|
222
|
+
// crawl is for.
|
|
223
|
+
actionSince: since,
|
|
219
224
|
})) {
|
|
220
225
|
counts.contradictions += 1;
|
|
221
226
|
anomalies.push({
|
|
@@ -55,6 +55,49 @@ export declare function resolveDaemonForProject(projectId: string | undefined, h
|
|
|
55
55
|
export declare function adoptable(daemonProjectId: string | undefined, callerProjectId: string | undefined): boolean;
|
|
56
56
|
/** The project a daemon on `port` claims, or undefined when it claims none / is not registered. */
|
|
57
57
|
export declare function daemonProjectAt(port: number, home: string): string | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Live daemons OTHER than `myPort` that an app for this project has connected to.
|
|
60
|
+
*
|
|
61
|
+
* The split brain this whole module was written to prevent has a second half nobody was reporting.
|
|
62
|
+
* `resolveMcpPort` correctly refuses to adopt a stranger's daemon and relocates to an OS-assigned
|
|
63
|
+
* port — and the APP, which resolves its own port independently (core's `pickDaemonPort` in the
|
|
64
|
+
* build plugin, falling back to the documented default when nothing matches), can land on the
|
|
65
|
+
* stranger. The pairing token is per USER, not per project, so that connect SUCCEEDS: the app is
|
|
66
|
+
* live on one daemon and the agent is attached to a different, empty one.
|
|
67
|
+
*
|
|
68
|
+
* Every individual check is then green. The browser shows an instrumented page, `reticle status`
|
|
69
|
+
* reports a session, the agent reports Reticle registered and enabled — and the agent's tools see
|
|
70
|
+
* nothing, with a no-session diagnosis that tells it to start a dev server that is already running.
|
|
71
|
+
*
|
|
72
|
+
* `connectedOn` is injected rather than imported so this stays free of the session layer (which
|
|
73
|
+
* imports from here) and so the rule is testable without a real `~/.reticle`. Callers pass
|
|
74
|
+
* `hasProjectConnectedBefore` bound to the same projectId.
|
|
75
|
+
*
|
|
76
|
+
* Only LIVE daemons are named: a port a session used yesterday is history, not a split.
|
|
77
|
+
*/
|
|
78
|
+
export declare function daemonsServingProjectElsewhere(projectId: string | undefined, myPort: number, home: string, alive: (pid: number) => boolean, connectedOn: (port: number) => boolean): readonly number[];
|
|
79
|
+
/**
|
|
80
|
+
* The sentence for a split brain, or `undefined` when there is none.
|
|
81
|
+
*
|
|
82
|
+
* Stated as a fact about two ports rather than a hedge, because unlike the sibling-port observation
|
|
83
|
+
* next door this is not "something is listening somewhere" — it is this project's own connection
|
|
84
|
+
* record naming a daemon that is not the one being asked. The wording says which side is empty,
|
|
85
|
+
* since the failure is invisible precisely because both sides look healthy from where they stand.
|
|
86
|
+
*/
|
|
87
|
+
export declare function splitBrainNote(myPort: number, elsewhere: readonly number[]): string | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* The other stance on the same split, for a command standing on the port that HAS the app.
|
|
90
|
+
*
|
|
91
|
+
* `splitBrainNote` is asked from the empty daemon and reads the connection record. From the daemon
|
|
92
|
+
* the app is connected to there is nothing missing to notice — sessions are live, every check is
|
|
93
|
+
* green — and the only visible fact is that this project ALSO owns a live daemon somewhere else.
|
|
94
|
+
* One project, two daemons, is a split whichever end you are standing on, and saying it from both
|
|
95
|
+
* ends is the difference between a user inferring it from two green checkmarks and being told.
|
|
96
|
+
*
|
|
97
|
+
* Silent for a caller with no project (it owns no daemon to be split from) and silent when the port
|
|
98
|
+
* being inspected is the project's own.
|
|
99
|
+
*/
|
|
100
|
+
export declare function wrongDaemonNote(inspectedPort: number, ownPort: number | undefined): string | undefined;
|
|
58
101
|
/**
|
|
59
102
|
* The port this agent's MCP proxy should serve, decided BEFORE the proxy starts.
|
|
60
103
|
*
|
|
@@ -89,6 +89,75 @@ export function adoptable(daemonProjectId, callerProjectId) {
|
|
|
89
89
|
export function daemonProjectAt(port, home) {
|
|
90
90
|
return readDaemonRegistry(home).find((e) => e.port === port)?.projectId;
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Live daemons OTHER than `myPort` that an app for this project has connected to.
|
|
94
|
+
*
|
|
95
|
+
* The split brain this whole module was written to prevent has a second half nobody was reporting.
|
|
96
|
+
* `resolveMcpPort` correctly refuses to adopt a stranger's daemon and relocates to an OS-assigned
|
|
97
|
+
* port — and the APP, which resolves its own port independently (core's `pickDaemonPort` in the
|
|
98
|
+
* build plugin, falling back to the documented default when nothing matches), can land on the
|
|
99
|
+
* stranger. The pairing token is per USER, not per project, so that connect SUCCEEDS: the app is
|
|
100
|
+
* live on one daemon and the agent is attached to a different, empty one.
|
|
101
|
+
*
|
|
102
|
+
* Every individual check is then green. The browser shows an instrumented page, `reticle status`
|
|
103
|
+
* reports a session, the agent reports Reticle registered and enabled — and the agent's tools see
|
|
104
|
+
* nothing, with a no-session diagnosis that tells it to start a dev server that is already running.
|
|
105
|
+
*
|
|
106
|
+
* `connectedOn` is injected rather than imported so this stays free of the session layer (which
|
|
107
|
+
* imports from here) and so the rule is testable without a real `~/.reticle`. Callers pass
|
|
108
|
+
* `hasProjectConnectedBefore` bound to the same projectId.
|
|
109
|
+
*
|
|
110
|
+
* Only LIVE daemons are named: a port a session used yesterday is history, not a split.
|
|
111
|
+
*/
|
|
112
|
+
export function daemonsServingProjectElsewhere(projectId, myPort, home, alive, connectedOn) {
|
|
113
|
+
if (projectId === undefined || 0 === projectId.length)
|
|
114
|
+
return [];
|
|
115
|
+
return readDaemonRegistry(home)
|
|
116
|
+
.filter((entry) => entry.port !== myPort && alive(entry.pid) && connectedOn(entry.port))
|
|
117
|
+
.map((entry) => entry.port)
|
|
118
|
+
.sort((a, b) => a - b);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The sentence for a split brain, or `undefined` when there is none.
|
|
122
|
+
*
|
|
123
|
+
* Stated as a fact about two ports rather than a hedge, because unlike the sibling-port observation
|
|
124
|
+
* next door this is not "something is listening somewhere" — it is this project's own connection
|
|
125
|
+
* record naming a daemon that is not the one being asked. The wording says which side is empty,
|
|
126
|
+
* since the failure is invisible precisely because both sides look healthy from where they stand.
|
|
127
|
+
*/
|
|
128
|
+
export function splitBrainNote(myPort, elsewhere) {
|
|
129
|
+
if (0 === elsewhere.length)
|
|
130
|
+
return undefined;
|
|
131
|
+
const listed = elsewhere.map((port) => `:${String(port)}`).join(', ');
|
|
132
|
+
const first = elsewhere[0] ?? myPort;
|
|
133
|
+
return (`this project's app is connected to a DIFFERENT Reticle daemon (${listed}), while this one is ` +
|
|
134
|
+
`on :${String(myPort)} — two daemons for one project, and the agent is attached to the empty ` +
|
|
135
|
+
'half. Nothing is wrong with the app or the wiring. Stop the other daemon with ' +
|
|
136
|
+
`\`reticle stop --port ${String(first)}\` and restart the dev server so the app rediscovers ` +
|
|
137
|
+
'this one, or start the agent and the dev server from the same project directory so both ' +
|
|
138
|
+
'resolve the same daemon.');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The other stance on the same split, for a command standing on the port that HAS the app.
|
|
142
|
+
*
|
|
143
|
+
* `splitBrainNote` is asked from the empty daemon and reads the connection record. From the daemon
|
|
144
|
+
* the app is connected to there is nothing missing to notice — sessions are live, every check is
|
|
145
|
+
* green — and the only visible fact is that this project ALSO owns a live daemon somewhere else.
|
|
146
|
+
* One project, two daemons, is a split whichever end you are standing on, and saying it from both
|
|
147
|
+
* ends is the difference between a user inferring it from two green checkmarks and being told.
|
|
148
|
+
*
|
|
149
|
+
* Silent for a caller with no project (it owns no daemon to be split from) and silent when the port
|
|
150
|
+
* being inspected is the project's own.
|
|
151
|
+
*/
|
|
152
|
+
export function wrongDaemonNote(inspectedPort, ownPort) {
|
|
153
|
+
if (ownPort === undefined || ownPort === inspectedPort)
|
|
154
|
+
return undefined;
|
|
155
|
+
return (`this is not this project's own daemon: the registry says it owns a live daemon on ` +
|
|
156
|
+
`:${String(ownPort)} and this command is looking at :${String(inspectedPort)}. Two daemons for ` +
|
|
157
|
+
'one project means the half your agent is attached to and the half your app is connected to can ' +
|
|
158
|
+
'be different processes, with both reporting healthy. Stop one of them and restart the dev ' +
|
|
159
|
+
'server so everything lands on the same port.');
|
|
160
|
+
}
|
|
92
161
|
/**
|
|
93
162
|
* The port this agent's MCP proxy should serve, decided BEFORE the proxy starts.
|
|
94
163
|
*
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type DevServerEntry } from '@reticlehq/core';
|
|
2
|
+
/** Injected so the reading is unit-testable without a real home or real processes. */
|
|
3
|
+
export interface DevServerIo {
|
|
4
|
+
readdir: (dir: string) => string[];
|
|
5
|
+
readFile: (path: string) => string;
|
|
6
|
+
isAlive: (pid: number) => boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Every dev server that announced itself and is still running, lowest port first.
|
|
10
|
+
*
|
|
11
|
+
* Fails soft at every step. This runs on the path that explains to a user why setup has not worked,
|
|
12
|
+
* so a half-written file, a hand-edit, or a missing directory must degrade the answer rather than
|
|
13
|
+
* replace the diagnosis with a stack trace.
|
|
14
|
+
*/
|
|
15
|
+
export declare function readDevServers(home: string, io?: DevServerIo): DevServerEntry[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading what dev servers have announced about themselves.
|
|
3
|
+
*
|
|
4
|
+
* The consumer half of `devserver-<port>.json`. A build plugin writes one when its dev server is
|
|
5
|
+
* actually listening; this reads them back, so `init` and `status` can tell three states apart that
|
|
6
|
+
* used to be one:
|
|
7
|
+
*
|
|
8
|
+
* nothing announced no dev server with Reticle loaded is running
|
|
9
|
+
* announced, no page the bundle is wired and no browser dialled — and the entry names the URL
|
|
10
|
+
* announced + session working
|
|
11
|
+
*
|
|
12
|
+
* The middle one is the case that had no message. It was folded into "restart your dev server and
|
|
13
|
+
* load the app", which is two instructions where only the second one applies, addressed to someone
|
|
14
|
+
* who has already done the first.
|
|
15
|
+
*
|
|
16
|
+
* Fs plumbing only — the selection rule is `liveDevServers` in core, shared with anything else that
|
|
17
|
+
* reads this registry, for the same reason `pickDaemonPort` lives there.
|
|
18
|
+
*/
|
|
19
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { DevServerEntrySchema, devServerRegistryPort, liveDevServers, } from '@reticlehq/core';
|
|
22
|
+
/** process.kill(pid, 0) throws iff the process is gone — the same probe the daemon registry uses. */
|
|
23
|
+
function isAliveNow(pid) {
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const nodeIo = {
|
|
33
|
+
readdir: (dir) => readdirSync(dir),
|
|
34
|
+
readFile: (path) => readFileSync(path, 'utf8'),
|
|
35
|
+
isAlive: isAliveNow,
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Every dev server that announced itself and is still running, lowest port first.
|
|
39
|
+
*
|
|
40
|
+
* Fails soft at every step. This runs on the path that explains to a user why setup has not worked,
|
|
41
|
+
* so a half-written file, a hand-edit, or a missing directory must degrade the answer rather than
|
|
42
|
+
* replace the diagnosis with a stack trace.
|
|
43
|
+
*/
|
|
44
|
+
export function readDevServers(home, io = nodeIo) {
|
|
45
|
+
let files;
|
|
46
|
+
try {
|
|
47
|
+
files = io.readdir(home);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return []; // No state directory yet — nothing has ever run here.
|
|
51
|
+
}
|
|
52
|
+
const entries = [];
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
// Strict on the filename BEFORE reading: the daemon's own `daemon-<port>.json` is a sibling, and
|
|
55
|
+
// parsing one as a dev server would report the daemon itself as an instrumented app.
|
|
56
|
+
if (null === devServerRegistryPort(file))
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = DevServerEntrySchema.safeParse(JSON.parse(io.readFile(join(home, file))));
|
|
60
|
+
if (parsed.success)
|
|
61
|
+
entries.push(parsed.data);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Truncated, mid-write, or hand-edited. One bad file is not a reason to report none.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return liveDevServers(entries, io.isAlive);
|
|
68
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a woken MCP proxy may do with whatever is sitting on its port.
|
|
3
|
+
*
|
|
4
|
+
* Pulled out as a pure function because the wake path lives inside a long side-effecting closure in
|
|
5
|
+
* `mcp-command.ts`, and the decision it was getting wrong is three lines of reasoning that deserve a
|
|
6
|
+
* test of their own.
|
|
7
|
+
*
|
|
8
|
+
* `resolveMcpPort` asks the identity question at BOOT and relocates rather than adopting a stranger.
|
|
9
|
+
* The wake path asked only whether a daemon answered, so after an idle exit freed the port and
|
|
10
|
+
* another project's daemon bound it, a dormant proxy could reattach to a daemon that was not its
|
|
11
|
+
* own — and then serve an agent verdicts about a different application. `adoptable` is the same rule
|
|
12
|
+
* boot uses; this is just the wake path finally asking it.
|
|
13
|
+
*/
|
|
14
|
+
import { PortPresence } from './port-presence.js';
|
|
15
|
+
export declare const WakeAction: {
|
|
16
|
+
/** A daemon we may use is already here. */
|
|
17
|
+
readonly USE: "use";
|
|
18
|
+
/** Nothing is listening: start one. */
|
|
19
|
+
readonly SPAWN: "spawn";
|
|
20
|
+
/** Somebody else's. Go dormant with a reason rather than pretend the wake succeeded. */
|
|
21
|
+
readonly REFUSE: "refuse";
|
|
22
|
+
};
|
|
23
|
+
export type WakeAction = (typeof WakeAction)[keyof typeof WakeAction];
|
|
24
|
+
/**
|
|
25
|
+
* Identity is asked only of a port that actually holds a daemon.
|
|
26
|
+
*
|
|
27
|
+
* A FREE port must stay spawnable whatever the registry says about it, because a stale entry is the
|
|
28
|
+
* registry's ordinary state for the moment after any daemon exits — and refusing there would turn
|
|
29
|
+
* every idle retirement into a permanent lockout, which is the failure this whole path exists to
|
|
30
|
+
* prevent.
|
|
31
|
+
*/
|
|
32
|
+
export declare function decideWake(presence: PortPresence, daemonProjectId: string | undefined, callerProjectId: string | undefined): WakeAction;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a woken MCP proxy may do with whatever is sitting on its port.
|
|
3
|
+
*
|
|
4
|
+
* Pulled out as a pure function because the wake path lives inside a long side-effecting closure in
|
|
5
|
+
* `mcp-command.ts`, and the decision it was getting wrong is three lines of reasoning that deserve a
|
|
6
|
+
* test of their own.
|
|
7
|
+
*
|
|
8
|
+
* `resolveMcpPort` asks the identity question at BOOT and relocates rather than adopting a stranger.
|
|
9
|
+
* The wake path asked only whether a daemon answered, so after an idle exit freed the port and
|
|
10
|
+
* another project's daemon bound it, a dormant proxy could reattach to a daemon that was not its
|
|
11
|
+
* own — and then serve an agent verdicts about a different application. `adoptable` is the same rule
|
|
12
|
+
* boot uses; this is just the wake path finally asking it.
|
|
13
|
+
*/
|
|
14
|
+
import { PortPresence } from './port-presence.js';
|
|
15
|
+
import { adoptable } from './daemon-resolve.js';
|
|
16
|
+
export const WakeAction = {
|
|
17
|
+
/** A daemon we may use is already here. */
|
|
18
|
+
USE: 'use',
|
|
19
|
+
/** Nothing is listening: start one. */
|
|
20
|
+
SPAWN: 'spawn',
|
|
21
|
+
/** Somebody else's. Go dormant with a reason rather than pretend the wake succeeded. */
|
|
22
|
+
REFUSE: 'refuse',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Identity is asked only of a port that actually holds a daemon.
|
|
26
|
+
*
|
|
27
|
+
* A FREE port must stay spawnable whatever the registry says about it, because a stale entry is the
|
|
28
|
+
* registry's ordinary state for the moment after any daemon exits — and refusing there would turn
|
|
29
|
+
* every idle retirement into a permanent lockout, which is the failure this whole path exists to
|
|
30
|
+
* prevent.
|
|
31
|
+
*/
|
|
32
|
+
export function decideWake(presence, daemonProjectId, callerProjectId) {
|
|
33
|
+
if (PortPresence.FREE === presence)
|
|
34
|
+
return WakeAction.SPAWN;
|
|
35
|
+
if (PortPresence.DAEMON !== presence)
|
|
36
|
+
return WakeAction.REFUSE;
|
|
37
|
+
return adoptable(daemonProjectId, callerProjectId) ? WakeAction.USE : WakeAction.REFUSE;
|
|
38
|
+
}
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { sessionRoot } from '../project/session-root.js';
|
|
3
3
|
import { asNumber, asString } from '../tools/tools-helpers.js';
|
|
4
4
|
import { ReticleTool } from '../tools/tool-names.js';
|
|
5
|
+
import { countSchema } from '../tools/numeric-bounds.js';
|
|
5
6
|
import { readContract } from '../project/reticle-dir.js';
|
|
6
7
|
import { buildDomainModel } from './domain-model.js';
|
|
7
8
|
import { proposeInstrumentation } from '../oracles/self-instrument.js';
|
|
@@ -19,8 +20,7 @@ export const DOMAIN_TOOLS = [
|
|
|
19
20
|
name: ReticleTool.DOMAIN,
|
|
20
21
|
description: 'Read the app domain model BEFORE testing: every saved flow with its assertion grade, the consequence that MUST hold for it (mustHold = what it actually tests), the anchors/signals it exercises, plus GAPS — declared signals/testids that NO flow asserts (untested intent), and flows that assert no observable consequence. Use this to decide what to test and where the real risk is, instead of crawling the whole app. Reads .reticle/flows/ + .reticle/contract.json (no browser needed).',
|
|
21
22
|
inputSchema: {
|
|
22
|
-
limit:
|
|
23
|
-
.number()
|
|
23
|
+
limit: countSchema
|
|
24
24
|
.optional()
|
|
25
25
|
.describe('Most-recent N flows to detail. Defaults to 25 — the per-flow list grows with every saved flow (measured at ~11KB / ~2,700 tokens at 22 flows) and a large project would spend most of an agent context on flows it is not asking about. `flowCount` is always the true total, and the gaps/coverage summary is computed over ALL flows regardless, so capping the detail never changes the analysis.'),
|
|
26
26
|
},
|
|
@@ -138,6 +138,24 @@ export interface ContradictionOptions {
|
|
|
138
138
|
* treats absence as current on both sides.
|
|
139
139
|
*/
|
|
140
140
|
currentEditEpoch?: number | undefined;
|
|
141
|
+
/**
|
|
142
|
+
* The page under test, as the session last recorded it — the app's own origin, in URL form.
|
|
143
|
+
*
|
|
144
|
+
* The first-party/third-party axis. Every rule below asks "did the app disagree with itself", and
|
|
145
|
+
* a failed analytics beacon is not the app: reported independently from several apps, any
|
|
146
|
+
* analytics package installed was enough to grade a correct drive `contradicted`, and on one app
|
|
147
|
+
* EVERY assertion came back that way forever, because it fires a branding call on page load. A
|
|
148
|
+
* verdict field that answers "no" to everything has stopped being a verdict field.
|
|
149
|
+
*
|
|
150
|
+
* Third-party traffic is dropped here rather than reported at a lower severity, for the reason the
|
|
151
|
+
* dev-tooling split is: the rules below would each have to learn to say it. The exclusion is never
|
|
152
|
+
* silent — the URLs ride out in the same disclosure line the toolchain's do — and the calls
|
|
153
|
+
* themselves are untouched in `reticle_network` and the event timeline.
|
|
154
|
+
*
|
|
155
|
+
* Undefined disables the axis, exactly as an undefined `currentDocumentId` disables the document
|
|
156
|
+
* one: a caller that cannot say which page is under test gets the behaviour it had before this.
|
|
157
|
+
*/
|
|
158
|
+
appOrigin?: string | undefined;
|
|
141
159
|
}
|
|
142
160
|
/**
|
|
143
161
|
* Cross-channel contradictions in this window, with the edit-epoch caveat attached when it applies.
|