@reefclaw/openclaw-plugin 0.1.23 → 0.1.25

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.
Files changed (95) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.d.ts +3 -1
  3. package/bridge/connector.js +51 -4
  4. package/bridge/gateway/heartbeat-cron.js +31 -7
  5. package/bridge/gateway/poller.d.ts +5 -0
  6. package/bridge/gateway/poller.js +9 -0
  7. package/bridge/index.js +21 -0
  8. package/bridge/provider.d.ts +15 -0
  9. package/bridge/providers/connector-update.d.ts +89 -0
  10. package/bridge/providers/connector-update.js +212 -0
  11. package/bridge/providers/emergency-commands.d.ts +36 -0
  12. package/bridge/providers/emergency-commands.js +91 -0
  13. package/bridge/providers/gateway.d.ts +26 -1
  14. package/bridge/providers/gateway.js +159 -8
  15. package/bridge/providers/mock.js +1 -0
  16. package/bridge/shock-wake.d.ts +80 -0
  17. package/bridge/shock-wake.js +291 -0
  18. package/bridge/types.d.ts +5 -1
  19. package/bridge/types.js +5 -0
  20. package/bridge/utils/instance-id.d.ts +3 -0
  21. package/bridge/utils/instance-id.js +48 -0
  22. package/ccxt/binance-private.js +2 -1
  23. package/ccxt/binance-public.js +6 -1
  24. package/config/agent-config-client.d.ts +7 -2
  25. package/config/agent-config-client.js +17 -0
  26. package/config/agent-config-poller.js +5 -1
  27. package/config/brackets-config.d.ts +2 -1
  28. package/config/brackets-config.js +25 -3
  29. package/config/gate-store.d.ts +12 -0
  30. package/config/gate-store.js +26 -2
  31. package/config/loss-streak-config.d.ts +2 -0
  32. package/config/loss-streak-config.js +33 -0
  33. package/config/plugin-config-io.d.ts +19 -0
  34. package/config/plugin-config-io.js +24 -2
  35. package/config/reentry-cooldown-config.d.ts +7 -0
  36. package/config/reentry-cooldown-config.js +59 -0
  37. package/http/keepalive-fetch.d.ts +5 -0
  38. package/http/keepalive-fetch.js +50 -0
  39. package/index.js +77 -8
  40. package/ingest/position-auto-capture.js +49 -4
  41. package/ingest/position-decisions-client.d.ts +6 -0
  42. package/ingest/position-decisions-client.js +27 -9
  43. package/ingest/readiness-reporter.d.ts +23 -2
  44. package/ingest/readiness-reporter.js +56 -1
  45. package/live/approval-lifecycle.d.ts +10 -0
  46. package/live/approval-lifecycle.js +16 -2
  47. package/live/microstructure-assembler.js +11 -2
  48. package/live/proposal-decision-listener.d.ts +21 -0
  49. package/live/proposal-decision-listener.js +39 -0
  50. package/live/proposal-manager.d.ts +12 -0
  51. package/live/proposal-manager.js +47 -0
  52. package/live/stop-watcher.d.ts +16 -1
  53. package/live/stop-watcher.js +48 -8
  54. package/onboarding/runtime.js +4 -0
  55. package/openclaw.plugin.json +1 -1
  56. package/package.json +38 -38
  57. package/persistence/state-manager.d.ts +7 -0
  58. package/persistence/state-manager.js +28 -1
  59. package/portfolio/directional-scoreboard.d.ts +17 -0
  60. package/portfolio/directional-scoreboard.js +71 -0
  61. package/portfolio/reentry-tracker.d.ts +38 -1
  62. package/portfolio/reentry-tracker.js +49 -0
  63. package/signals/change-of-character.d.ts +38 -0
  64. package/signals/change-of-character.js +93 -0
  65. package/simulator/exchange-simulator.d.ts +27 -1
  66. package/simulator/exchange-simulator.js +98 -38
  67. package/simulator/types.d.ts +11 -0
  68. package/skills/reefclaw/SKILL.md +2 -2
  69. package/strategy/evaluator.d.ts +4 -0
  70. package/tools/audit-bracket-protection.js +11 -7
  71. package/tools/close-position.js +10 -1
  72. package/tools/create-order.js +121 -9
  73. package/tools/get-funding-context.js +6 -1
  74. package/tools/get-liquidation-levels.js +5 -1
  75. package/tools/get-liquidation-pulse.js +7 -1
  76. package/tools/get-market-intel.js +2 -1
  77. package/tools/get-relevant-learnings.js +20 -1
  78. package/tools/get-resting-liquidity.js +6 -1
  79. package/tools/get-wave9-status.js +17 -0
  80. package/tools/hl-provision-agent-wallet.js +29 -11
  81. package/tools/intel-api.d.ts +9 -0
  82. package/tools/intel-api.js +32 -1
  83. package/tools/record-position-reviews.js +2 -2
  84. package/tools/reentry-cooldown.d.ts +33 -0
  85. package/tools/reentry-cooldown.js +74 -0
  86. package/tools/scan-pairs.d.ts +7 -0
  87. package/tools/scan-pairs.js +67 -11
  88. package/tools/set-exchange-credentials.js +19 -0
  89. package/tools/set-trading-mode.d.ts +6 -0
  90. package/tools/set-trading-mode.js +48 -1
  91. package/types.d.ts +7 -0
  92. package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
  93. package/venues/hyperliquid/hl-agent-wallet.js +32 -0
  94. package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
  95. package/venues/hyperliquid/hl-live-adapter.js +101 -13
@@ -30,11 +30,15 @@ class GateStore {
30
30
  apply(gates) {
31
31
  const next = gates ?? {};
32
32
  const changed = next.exitGate !== this.gates.exitGate ||
33
- next.positionReviewMode !== this.gates.positionReviewMode;
33
+ next.positionReviewMode !== this.gates.positionReviewMode ||
34
+ next.approvalMode !== this.gates.approvalMode ||
35
+ next.reentryCooldown !== this.gates.reentryCooldown;
34
36
  this.gates = { ...next };
35
37
  if (changed) {
36
38
  logger.info(TAG, `applied central gates: exitGate=${next.exitGate ?? UNSET} ` +
37
- `positionReviewMode=${next.positionReviewMode ?? UNSET}`);
39
+ `positionReviewMode=${next.positionReviewMode ?? UNSET} ` +
40
+ `approvalMode=${next.approvalMode ?? UNSET} ` +
41
+ `reentryCooldown=${next.reentryCooldown ?? UNSET}`);
38
42
  }
39
43
  }
40
44
  /** The central exitGate mode, or null when central has no value (or the
@@ -51,6 +55,26 @@ class GateStore {
51
55
  return null;
52
56
  return this.gates.positionReviewMode ?? null;
53
57
  }
58
+ /** The central approval.mode, or null when central has no value (or the
59
+ * kill-switch is on) — null tells the reader to fall back to the file.
60
+ *
61
+ * ★ Central can only ever say 'off' or 'per_trade'. It cannot enable shadow
62
+ * telemetry (that's the local APPROVAL_SHADOW_MODE env flag) and it cannot
63
+ * weaken the hardcoded safety floor — per_trade only ADDS a gate, and 'off'
64
+ * is the pre-existing autonomous behaviour, so neither value can leave a
65
+ * position unprotected. */
66
+ getApprovalMode() {
67
+ if (!centralGatesEnabled())
68
+ return null;
69
+ return this.gates.approvalMode ?? null;
70
+ }
71
+ /** The central reentryCooldown mode, or null when central has no value (or
72
+ * the kill-switch is on) — null tells the reader to fall back to the file. */
73
+ getReentryCooldown() {
74
+ if (!centralGatesEnabled())
75
+ return null;
76
+ return this.gates.reentryCooldown ?? null;
77
+ }
54
78
  /** Test-only. */
55
79
  __reset() {
56
80
  this.gates = {};
@@ -0,0 +1,2 @@
1
+ export type LossStreakSizingMode = 'off' | 'log' | 'enforce';
2
+ export declare function resolveLossStreakSizingMode(): LossStreakSizingMode;
@@ -0,0 +1,33 @@
1
+ // WS1 (docs/MARKET_ADAPTIVITY_PLAN.md §3) — mode resolver for the LIVE
2
+ // loss-streak sizing brake feed.
3
+ //
4
+ // preTradeRiskCheck has always had a graduated loss-streak brake (0.5× /
5
+ // 0.25× position size at the operator-tunable lossStreakHalfSize /
6
+ // lossStreakQuarterSize thresholds — never a hard block), but live fed it a
7
+ // hardcoded consecutiveLosses=0 ("would come from intelligence DB — use 0
8
+ // for now"). The real feed now comes from the ReentryTracker exit records
9
+ // (wasLoss + book tag, persisted).
10
+ //
11
+ // Env RC_LOSS_STREAK_SIZING:
12
+ // 'enforce' (DEFAULT since 2026-09-05) — feed the real streak to the risk
13
+ // check; the graduated size reduction applies on
14
+ // live exactly as it always has on paper. Promoted
15
+ // from 'log' after the pre-registered soak: 3 days
16
+ // of clean streak logs (1→3 tracked + reset
17
+ // correctly) and an 18/18 loss-sign agreement audit
18
+ // between the agent's r_multiple_at_close and DB
19
+ // realized_r.
20
+ // 'log' — compute + log on entries; sizing UNAFFECTED
21
+ // (riskCheck still sees 0). The rollout soak mode.
22
+ // 'off' — no compute, no log; byte-identical to the
23
+ // pre-WS1 path (kill-switch).
24
+ //
25
+ // Deliberately env-based (not the central gate channel): it is a
26
+ // live/paper-parity bug fix on a risk-reduction mechanism, not a new policy
27
+ // ladder — and its only enforce-direction effect is SMALLER size.
28
+ export function resolveLossStreakSizingMode() {
29
+ const raw = (process.env.RC_LOSS_STREAK_SIZING ?? '').toLowerCase();
30
+ if (raw === 'off' || raw === 'log')
31
+ return raw;
32
+ return 'enforce';
33
+ }
@@ -197,6 +197,25 @@ export interface PluginConfigFile {
197
197
  stopWatcher?: {
198
198
  intervalMs?: number;
199
199
  };
200
+ /** Re-entry cooldown gate — blocks (mode-laddered) a NEW create_order entry
201
+ * on a symbol whose last close within `minutes` was a LOSS. See
202
+ * plugin/src/tools/reentry-cooldown.ts for the evidence + semantics.
203
+ *
204
+ * mode='off' (default) — gate never runs; behaviour identical to today.
205
+ * mode='shadow' — gate runs; verdict logged + tagged into
206
+ * position_entries.metadata.reentry_cooldown;
207
+ * the order always fires.
208
+ * mode='observe' — as shadow, but a triggered verdict logs at WARN.
209
+ * mode='enforce' — triggered verdict hard-rejects create_order.
210
+ *
211
+ * `mode` here is the LOCAL fallback — once the central gate
212
+ * (agent_config.gates.reentryCooldown) is set, central rules (kill-switch
213
+ * RC_CENTRAL_GATES=off). `minutes` is local-only (default 60, clamped
214
+ * 5–1440). */
215
+ reentryCooldown?: {
216
+ mode?: 'off' | 'shadow' | 'observe' | 'enforce';
217
+ minutes?: number;
218
+ };
200
219
  [extra: string]: unknown;
201
220
  }
202
221
  export declare function defaultConfigPath(): string;
@@ -10,7 +10,7 @@
10
10
  // operator-only tool gated on dashboard provenance (verifyOperatorProvenance,
11
11
  // audit F12) — the agent cannot reach these writes conversationally. The file
12
12
  // is the plugin's OWN config store; nothing here touches OpenClaw's config.
13
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { logger } from '../logger.js';
@@ -82,17 +82,39 @@ export function loadMicroLiveConfig(path) {
82
82
  catch { /* best-effort — adapter default applies */ }
83
83
  return undefined;
84
84
  }
85
+ // mtime(ns)+size memo: several hot paths re-read this file per tool call
86
+ // (approval mode, bracket mode/requirements, exit gate, review mode, the
87
+ // microstructure flag × N symbols on the review path). A write always bumps
88
+ // mtime, so the documented per-call hot-reload semantics are preserved
89
+ // exactly — an unchanged file just costs one stat instead of read+parse.
90
+ // structuredClone on both sides keeps today's fresh-object-per-call contract.
91
+ const readMemo = new Map();
85
92
  /** Read the config file. Returns `{}` if the file doesn't exist.
86
93
  * Throws if the file exists but is unreadable or not valid JSON — callers
87
94
  * should treat that as an abort signal, not silently overwrite. */
88
95
  export function readPluginConfig(path = defaultConfigPath()) {
89
- if (!existsSync(path))
96
+ if (!existsSync(path)) {
97
+ readMemo.delete(path);
90
98
  return {};
99
+ }
100
+ let stat;
101
+ try {
102
+ const s = statSync(path, { bigint: true });
103
+ stat = { mtimeNs: s.mtimeNs, size: s.size };
104
+ const hit = readMemo.get(path);
105
+ if (hit && hit.mtimeNs === stat.mtimeNs && hit.size === stat.size) {
106
+ return structuredClone(hit.parsed);
107
+ }
108
+ }
109
+ catch { /* stat raced a delete — fall through to the plain read */ }
91
110
  const raw = readFileSync(path, 'utf-8');
92
111
  const parsed = JSON.parse(raw);
93
112
  if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
94
113
  throw new Error(`plugin-config.json root is not an object`);
95
114
  }
115
+ if (stat) {
116
+ readMemo.set(path, { ...stat, parsed: structuredClone(parsed) });
117
+ }
96
118
  return parsed;
97
119
  }
98
120
  /** Apply a patch on top of the existing file and write atomically.
@@ -0,0 +1,7 @@
1
+ import { type PluginConfigFile } from './plugin-config-io.js';
2
+ import type { ReentryCooldownMode } from '../tools/reentry-cooldown.js';
3
+ export declare const DEFAULT_REENTRY_COOLDOWN_MINUTES = 60;
4
+ export declare function getReentryCooldownMode(config?: PluginConfigFile): ReentryCooldownMode;
5
+ export declare function loadReentryCooldownMode(): ReentryCooldownMode;
6
+ export declare function getReentryCooldownMinutes(config?: PluginConfigFile): number;
7
+ export declare function loadReentryCooldownMinutes(): number;
@@ -0,0 +1,59 @@
1
+ // Feature-flag readers for the re-entry cooldown gate (create_order).
2
+ //
3
+ // Mode default 'off' so the gate ships dead-code; the cooldown window default
4
+ // (60 min) matches the 2026-09-02 measurement window that motivated the gate.
5
+ // Mode resolution follows the exitGate pattern (config-service slice 2):
6
+ //
7
+ // central (agent_config.gates.reentryCooldown via gate-store)
8
+ // → plugin-config.json reentryCooldown.mode
9
+ // → 'off'
10
+ //
11
+ // create_order reads the mode PER CALL, so a dashboard/API flip via
12
+ // scripts/enable-reentry-cooldown.py hot-applies within one config poll —
13
+ // no restart. Kill-switch RC_CENTRAL_GATES=off hands control back to the
14
+ // local file. The minutes knob is LOCAL-only (mechanism tunable, not a
15
+ // ladder) — central carries only the mode.
16
+ import { readPluginConfig } from './plugin-config-io.js';
17
+ import { gateStore } from './gate-store.js';
18
+ const VALID_MODES = new Set([
19
+ 'off',
20
+ 'shadow',
21
+ 'observe',
22
+ 'enforce',
23
+ ]);
24
+ export const DEFAULT_REENTRY_COOLDOWN_MINUTES = 60;
25
+ const MIN_COOLDOWN_MINUTES = 5;
26
+ const MAX_COOLDOWN_MINUTES = 1440;
27
+ export function getReentryCooldownMode(config) {
28
+ const raw = config?.reentryCooldown?.mode;
29
+ if (typeof raw === 'string' && VALID_MODES.has(raw)) {
30
+ return raw;
31
+ }
32
+ return 'off';
33
+ }
34
+ export function loadReentryCooldownMode() {
35
+ const central = gateStore.getReentryCooldown();
36
+ if (central)
37
+ return central;
38
+ try {
39
+ return getReentryCooldownMode(readPluginConfig());
40
+ }
41
+ catch {
42
+ return 'off';
43
+ }
44
+ }
45
+ export function getReentryCooldownMinutes(config) {
46
+ const raw = config?.reentryCooldown?.minutes;
47
+ if (typeof raw === 'number' && Number.isFinite(raw)) {
48
+ return Math.max(MIN_COOLDOWN_MINUTES, Math.min(MAX_COOLDOWN_MINUTES, raw));
49
+ }
50
+ return DEFAULT_REENTRY_COOLDOWN_MINUTES;
51
+ }
52
+ export function loadReentryCooldownMinutes() {
53
+ try {
54
+ return getReentryCooldownMinutes(readPluginConfig());
55
+ }
56
+ catch {
57
+ return DEFAULT_REENTRY_COOLDOWN_MINUTES;
58
+ }
59
+ }
@@ -0,0 +1,5 @@
1
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
2
+ /** Drop-in fetch with connection keep-alive; falls back to global fetch when
3
+ * undici is unavailable, and defers to globalThis.fetch whenever it has been
4
+ * replaced (mocks/instrumentation). */
5
+ export declare function keepAliveFetch(url: string, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,50 @@
1
+ // Keep-alive HTTP for the plugin's intel/webapp clients.
2
+ //
3
+ // Node's built-in fetch closes idle sockets after undici's 4s default, and the
4
+ // gap between two agent tool calls is LLM think-time (seconds to tens of
5
+ // seconds) — so every intel/webapp call was paying a fresh TCP+TLS handshake
6
+ // (~2 RTTs) before any server work started. A dedicated undici Agent with a
7
+ // 60s idle timeout holds the connection across those gaps.
8
+ //
9
+ // undici loads via createRequire (same rule as CCXT — see
10
+ // docs/CLAUDE/plugin-integration.md) and the whole module FAILS OPEN to the
11
+ // global fetch: dist-only overlay deploys land on boxes whose node_modules
12
+ // predate this dependency, and a missing package must degrade to today's
13
+ // behaviour, never crash the connector.
14
+ import { createRequire } from 'node:module';
15
+ // Captured at module load. When someone REPLACES globalThis.fetch later
16
+ // (vitest fetch mocks, tracing wrappers), keepAliveFetch honors the
17
+ // replacement instead of undici — otherwise every fetch-stubbing test (and
18
+ // any legitimate instrumentation) would be silently bypassed onto the real
19
+ // network.
20
+ const nativeFetch = globalThis.fetch;
21
+ let cached;
22
+ function build() {
23
+ try {
24
+ const req = createRequire(import.meta.url);
25
+ const undici = req('undici');
26
+ const dispatcher = new undici.Agent({
27
+ keepAliveTimeout: 60_000,
28
+ keepAliveMaxTimeout: 300_000,
29
+ connections: 16,
30
+ });
31
+ // undici's own fetch + Agent are used together: passing an npm-undici
32
+ // dispatcher to Node's built-in fetch can fail an instanceof check against
33
+ // the internal undici copy.
34
+ return (url, init) => undici.fetch(url, { ...init, dispatcher });
35
+ }
36
+ catch {
37
+ return (url, init) => fetch(url, init);
38
+ }
39
+ }
40
+ /** Drop-in fetch with connection keep-alive; falls back to global fetch when
41
+ * undici is unavailable, and defers to globalThis.fetch whenever it has been
42
+ * replaced (mocks/instrumentation). */
43
+ export function keepAliveFetch(url, init) {
44
+ if (globalThis.fetch !== nativeFetch) {
45
+ return globalThis.fetch(url, init);
46
+ }
47
+ if (!cached)
48
+ cached = build();
49
+ return cached(url, init);
50
+ }
package/index.js CHANGED
@@ -989,7 +989,10 @@ const paperTradingPlugin = {
989
989
  // gateway process must reload it before serving HTTP API calls.
990
990
  const reloadState = () => {
991
991
  try {
992
- const fresh = stateManager.loadSync();
992
+ // Skips the full read+parse (and replaceState) when the on-disk file
993
+ // hasn't changed since the last load — this runs on every paper-mode
994
+ // tool call and the file grows with trade history.
995
+ const fresh = stateManager.loadSyncIfChanged();
993
996
  if (fresh) {
994
997
  simulator.replaceState(fresh);
995
998
  }
@@ -1293,11 +1296,32 @@ const paperTradingPlugin = {
1293
1296
  // rows while collecting real ones). See docs/APPROVAL_MODE_DESIGN.md §12.
1294
1297
  const approvalShadowEnabled = (process.env.APPROVAL_SHADOW_MODE ?? '').trim() === '1';
1295
1298
  const resolveApprovalMode = () => {
1296
- try {
1297
- if (readPluginConfig().approval?.mode === 'per_trade')
1298
- return 'per_trade';
1299
+ // Precedence (config-service slice 4, mirrors loadExitGateMode /
1300
+ // loadPositionReviewMode):
1301
+ //
1302
+ // central (agent_config.gates.approvalMode) → plugin-config.json →
1303
+ // APPROVAL_SHADOW_MODE env → 'off'
1304
+ //
1305
+ // Central is enum-validated plugin-side (agent-config-client
1306
+ // validateGates) and can only ever say 'off' | 'per_trade' — it cannot
1307
+ // enable shadow telemetry and cannot weaken the safety floor. Kill-switch
1308
+ // RC_CENTRAL_GATES=off makes the store report null and the file rules
1309
+ // again. Read per call, so a dashboard flip applies within one poll
1310
+ // (~60 s) with no restart.
1311
+ const central = gateStore.getApprovalMode();
1312
+ if (central === 'per_trade')
1313
+ return 'per_trade';
1314
+ if (central !== 'off') {
1315
+ // No central value — fall back to the local file.
1316
+ try {
1317
+ if (readPluginConfig().approval?.mode === 'per_trade')
1318
+ return 'per_trade';
1319
+ }
1320
+ catch { /* config unreadable — env-only fallback below */ }
1299
1321
  }
1300
- catch { /* config unreadable env-only fallback below */ }
1322
+ // Central 'off' still permits local shadow telemetry: shadow does not
1323
+ // change trading behaviour (the order fires either way), it only writes
1324
+ // an extra row, and the env flag is the operator's own local choice.
1301
1325
  return approvalShadowEnabled ? 'shadow' : 'off';
1302
1326
  };
1303
1327
  // Proposal manager + listener credentials: built whenever ingest
@@ -1327,8 +1351,18 @@ const paperTradingPlugin = {
1327
1351
  logger.info(TAG, `Approval wiring active (mode=${bootApprovalMode}) → ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1328
1352
  }
1329
1353
  }
1354
+ else if (bootApprovalMode === 'per_trade') {
1355
+ // FAIL-CLOSED state: create_order will REFUSE every new entry until the
1356
+ // credentials are restored or approval.mode is set back to off. Loud at
1357
+ // boot AND at each refusal (create-order.ts) — a boot-only signal is how
1358
+ // this used to go unnoticed while orders fired without the gate.
1359
+ logger.error(TAG, `approval mode=per_trade but ingest token / REEFCLAW_USER_ID missing — proposals CANNOT ` +
1360
+ `reach the operator, so create_order will REFUSE every new entry (fail-closed). ` +
1361
+ `Restore the plugin connectionToken/WEBAPP_INGEST_TOKEN + REEFCLAW_USER_ID, or set ` +
1362
+ `approval.mode=off. Exits, stops, brackets and operator controls are unaffected.`);
1363
+ }
1330
1364
  else if (bootApprovalMode !== 'off') {
1331
- logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing — proposal path disabled (orders fire directly)`);
1365
+ logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing — shadow telemetry disabled (orders fire directly, as in off mode)`);
1332
1366
  }
1333
1367
  }
1334
1368
  // ---- Create exchange adapter based on trading mode ----
@@ -1351,12 +1385,21 @@ const paperTradingPlugin = {
1351
1385
  // Micro-live cap — same loader every runtime reconnect uses
1352
1386
  // (buildAdapter), so boot and reconnect can never disagree (F8).
1353
1387
  const microLiveConfig = loadMicroLiveConfig();
1354
- // Bracket-orders feature flag read from plugin-config at construction time.
1355
- // Default 'off' keeps legacy stop-watcher behaviour while the feature rolls out.
1388
+ // Bracket-orders feature flag read from plugin-config at construction
1389
+ // time. Since 2026-08-25 (E2E audit #3) the default on a live-Binance
1390
+ // box is 'enforce' — the old 'off' default meant a fresh npx install
1391
+ // flipped to live traded genuinely naked (no exchange stops AND the
1392
+ // mandatory-stop gate skipped, since it is wired behind
1393
+ // bracketsEnabled). An 'off' here can only be an explicit override.
1356
1394
  const bracketMode = loadBracketMode();
1357
1395
  if (bracketMode !== 'off') {
1358
1396
  logger.info(TAG, `Bracket orders enabled in mode=${bracketMode}`);
1359
1397
  }
1398
+ else {
1399
+ logger.warn(TAG, `LIVE Binance with brackets.mode='off' (explicit config override) — NO exchange-side ` +
1400
+ `stops; positions rely on the software watcher alone and the mandatory-stop ` +
1401
+ `pre-trade gate is OFF. The dashboard readiness banner will show this red.`);
1402
+ }
1360
1403
  // User-data WebSocket stream flag — same mode-ladder pattern as brackets.
1361
1404
  // Default 'off' keeps REST polling authoritative. Phase 1 ships dead-code;
1362
1405
  // the flag flip to 'shadow' / 'observe' / 'enforce' is operator-driven.
@@ -1384,6 +1427,11 @@ const paperTradingPlugin = {
1384
1427
  // F26: same wiring object as the Binance arm — the SIGTERM
1385
1428
  // drain covers both venues because it drains this client.
1386
1429
  tradeIngest,
1430
+ // Journal close capture (close-bypass fix, HL arm): without
1431
+ // this, every bracket SL/TP fill leaked as status='open'
1432
+ // until the reconciler healed it reason-less (50% of wisekid
1433
+ // 30d closes were reconciler_observed_flat).
1434
+ autoCapture,
1387
1435
  },
1388
1436
  }
1389
1437
  : {
@@ -1887,6 +1935,14 @@ const paperTradingPlugin = {
1887
1935
  pollIntervalMs: approvalCfg?.pollIntervalMs ?? 3_000,
1888
1936
  });
1889
1937
  },
1938
+ // The approval path just went away (mode flipped off, or live→PAPER).
1939
+ // Anything still pending is now un-fireable but still shows an Approve
1940
+ // button — cancel it rather than leave the operator a dead control.
1941
+ onApprovalPathDisabled: async () => {
1942
+ if (!proposalManagerCtx)
1943
+ return;
1944
+ await proposalManagerCtx.manager.cancelAll(proposalManagerCtx.userId, 'mode_disabled');
1945
+ },
1890
1946
  });
1891
1947
  runtime.setOnAdapterSwapped((a) => { void approvalLifecycle.onAdapterSwapped(a); });
1892
1948
  // Boot application — the same path every later swap takes.
@@ -2581,6 +2637,11 @@ const paperTradingPlugin = {
2581
2637
  decisionsClient: positionDecisionsClient,
2582
2638
  userId: positionDecisionsUserId,
2583
2639
  reentryTracker,
2640
+ // WS2 directional scoreboard (docs/MARKET_ADAPTIVITY_PLAN.md §3):
2641
+ // tracked positions from the state store (no exchange round-trip)
2642
+ // + book resolved per call so a paper↔live flip follows.
2643
+ openPositions: () => positionStateStore.getAll().map((e) => ({ side: e.side })),
2644
+ book: () => (runtime.adapter.isLive ? 'live' : 'paper'),
2584
2645
  })),
2585
2646
  },
2586
2647
  {
@@ -2878,6 +2939,14 @@ const paperTradingPlugin = {
2878
2939
  venue,
2879
2940
  publicApi: hlPublicApi ?? binanceApi,
2880
2941
  toolCount: toolNames.length,
2942
+ // live_stop_protection (E2E audit #3): what would stop a losing live
2943
+ // position. Deferred closure over the runtime so paper↔live flips and
2944
+ // adapter swaps surface on the next 5-min report without a restart.
2945
+ resolveStopProtection: () => ({
2946
+ tradingMode: runtime.mode,
2947
+ venueEnforced: runtime.adapter.bracketsAlwaysEnforced === true,
2948
+ bracketMode: loadBracketMode(),
2949
+ }),
2881
2950
  });
2882
2951
  maybeStartConnectorSupervisor();
2883
2952
  },
@@ -19,7 +19,7 @@
19
19
  // that hooks into the WS-ingest pipeline (see POSITION_DECISION_JOURNAL_PLAN
20
20
  // §5.1 for the longer-term design).
21
21
  import { logger } from '../logger.js';
22
- import { isBracketCid } from '../live/bracket-id.js';
22
+ import { isBracketClientId, parseBracketClientId } from '../live/bracket-id.js';
23
23
  import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
24
24
  import { fillPriceFromOrder } from '../live/fill-price.js';
25
25
  import { getSkillVersionCached, withSkillVersion } from './skill-version-reader.js';
@@ -306,12 +306,27 @@ export async function onClosePositionFilled(ctx, inputs, order) {
306
306
  };
307
307
  ctx.decisionsClient.postClose(ctx.userId, close);
308
308
  // Re-entry indication (issue #204) — record the exit so scan_pairs can flag
309
- // same-bar re-entries on this (symbol, setup).
309
+ // same-bar re-entries on this (symbol, setup), and so the reentryCooldown
310
+ // gate can see recent losses. Live agent-closes have no engine trade record
311
+ // here (exchange-exact PnL arrives later on the WS fill, racing this path),
312
+ // so the loss sign falls back to the agent's own r_multiple_at_close — the
313
+ // same validator-checked field the exit gate trusts. lossSource lets the
314
+ // shadow soak audit that sign against the DB before enforce.
315
+ const rRaw = inputs.closeAssessment?.['r_multiple_at_close'];
316
+ const rAtClose = typeof rRaw === 'number' && Number.isFinite(rRaw) ? rRaw : undefined;
310
317
  ctx.reentryTracker?.recordExit({
311
318
  symbol: inputs.symbol,
312
319
  setupType: stateEntry.setupType ?? paperTrade?.setupType,
313
320
  side: stateEntry.side,
314
- wasLoss: paperTrade ? paperTrade.netRealizedPnl < 0 : undefined,
321
+ wasLoss: paperTrade
322
+ ? paperTrade.netRealizedPnl < 0
323
+ : rAtClose != null
324
+ ? rAtClose < 0
325
+ : undefined,
326
+ lossSource: paperTrade ? 'paper_engine' : rAtClose != null ? 'assessment_r' : undefined,
327
+ mode: ctx.resolveMode?.(),
328
+ realizedR: rAtClose,
329
+ realizedPnl: paperTrade?.netRealizedPnl,
315
330
  closedAtMs: closeAtMs,
316
331
  });
317
332
  // Drop local state — symbol can re-enter as a new position.
@@ -388,6 +403,7 @@ export async function onAutoFlattenClose(ctx, inputs, lookup = {
388
403
  symbol: inputs.symbol,
389
404
  setupType: flattenState?.setupType,
390
405
  side: flattenState?.side ?? 'long',
406
+ mode: ctx.resolveMode?.(),
391
407
  closedAtMs: inputs.observedAtMs ?? Date.now(),
392
408
  });
393
409
  ctx.stateStore.remove(inputs.symbol);
@@ -421,6 +437,9 @@ export async function onStopWatcherClose(ctx, inputs) {
421
437
  setupType: stateEntry?.setupType ?? paperTrade?.setupType,
422
438
  side: stateEntry?.side ?? 'long',
423
439
  wasLoss: paperTrade ? paperTrade.netRealizedPnl < 0 : undefined,
440
+ lossSource: paperTrade ? 'paper_engine' : undefined,
441
+ mode: ctx.resolveMode?.(),
442
+ realizedPnl: paperTrade?.netRealizedPnl,
424
443
  closedAtMs: closeAtMs,
425
444
  });
426
445
  const dropState = () => { ctx.stateStore?.remove(inputs.symbol); };
@@ -627,7 +646,13 @@ async function handleReduceOnlyExit(ctx, fill) {
627
646
  // onClosePositionFilled) or a manual/external close — both are handled by
628
647
  // their own paths (close_position's rich reason+assessment, or the reconciler
629
648
  // backstop). Closing here would clobber the agent's close reasoning, so defer.
630
- const isBracket = fill.clientOrderId ? isBracketCid(fill.clientOrderId) : false;
649
+ // Recognition is VENUE-DISPATCHED (bracket-id rule): Binance `bkt…`/`rc-…`
650
+ // cids, Hyperliquid `0xbc7…` cloids — the Binance-only check silently
651
+ // classed every HL bracket fill as external and deferred it forever.
652
+ const cidVenue = ctx.venue ?? 'binance';
653
+ const isBracket = fill.clientOrderId
654
+ ? isBracketClientId(cidVenue, fill.clientOrderId)
655
+ : false;
631
656
  if (!isBracket) {
632
657
  logger.info(TAG, `${fill.symbol} flat via non-bracket reduce-only fill (cid=${fill.clientOrderId ?? 'none'}) — ` +
633
658
  `deferring close to close_position / reconciler backstop (no clobber)`);
@@ -643,6 +668,12 @@ async function handleReduceOnlyExit(ctx, fill) {
643
668
  'Auto-journaled from the WS fill — no close_position call (close-bypass path).',
644
669
  observedFrom: 'ws_reduce_only_fill',
645
670
  clientOrderId: fill.clientOrderId,
671
+ // Which protective leg fired ('stop' | 'target'), parsed from the cid.
672
+ // Kept in the assessment (not a new close reason) so the close_reason
673
+ // vocabulary stays stable for the miner's plan-adherence classifier.
674
+ leg: fill.clientOrderId
675
+ ? parseBracketClientId(cidVenue, fill.clientOrderId)?.role
676
+ : undefined,
646
677
  },
647
678
  scorecardVerdict: 'NO_GO',
648
679
  confluenceScore: 0,
@@ -668,6 +699,9 @@ async function handleReduceOnlyExit(ctx, fill) {
668
699
  setupType: stateEntry.setupType,
669
700
  side: stateEntry.side,
670
701
  wasLoss: realizedPnl < 0,
702
+ lossSource: 'ws_fill',
703
+ mode: ctx.resolveMode?.(),
704
+ realizedPnl,
671
705
  closedAtMs: fill.exchangeTimeMs ?? Date.now(),
672
706
  });
673
707
  ctx.stateStore.remove(fill.symbol);
@@ -733,5 +767,16 @@ export function buildEntryPlanMetadata(md) {
733
767
  j.note = rr.note;
734
768
  out.realization_rule = j;
735
769
  }
770
+ // Cooldown-gate measurement tag (snake_case per the canonical JSONB key
771
+ // rule) — present only when the gate triggered and the entry fired anyway.
772
+ const rc = md.reentryCooldown;
773
+ if (rc) {
774
+ out.reentry_cooldown = {
775
+ mode: rc.mode,
776
+ minutes_since_loss: rc.minutesSinceLoss,
777
+ cooldown_minutes: rc.cooldownMinutes,
778
+ would_block: rc.wouldBlock,
779
+ };
780
+ }
736
781
  return Object.keys(out).length > 0 ? out : undefined;
737
782
  }
@@ -322,6 +322,12 @@ export declare class PositionDecisionsClient {
322
322
  private fireAndForget;
323
323
  private run;
324
324
  private runReturning;
325
+ /** Decision-path GET budget. Every read caller degrades to empty/null on
326
+ * failure, so burning the write-grade budget (4 × 10s + backoff ≈ 42s
327
+ * worst case) of the agent's turn to reach an optional result is pure
328
+ * heartbeat latency. Background reads (reconcile sweep) override per call. */
329
+ private static readonly READ_MAX_ATTEMPTS;
330
+ private static readonly READ_DEADLINE_MS;
325
331
  private runGetReturning;
326
332
  private sleepBackoff;
327
333
  }
@@ -12,6 +12,7 @@
12
12
  // - postEntry/Review/Close are fire-and-forget (don't block the WS hot path).
13
13
  //
14
14
  // All four routes accept the same auth: Bearer + X-User-Id headers.
15
+ import { keepAliveFetch } from '../http/keepalive-fetch.js';
15
16
  import { logger, formatError } from '../logger.js';
16
17
  const TAG = 'position-decisions-client';
17
18
  export class PositionDecisionsClient {
@@ -25,7 +26,7 @@ export class PositionDecisionsClient {
25
26
  this.opts = {
26
27
  baseUrl: options.baseUrl.replace(/\/+$/, ''),
27
28
  ingestToken: options.ingestToken,
28
- fetchImpl: options.fetchImpl ?? fetch,
29
+ fetchImpl: options.fetchImpl ?? keepAliveFetch,
29
30
  requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
30
31
  maxAttempts: options.maxAttempts ?? 4,
31
32
  baseBackoffMs: options.baseBackoffMs ?? 250,
@@ -65,7 +66,12 @@ export class PositionDecisionsClient {
65
66
  qs.set('mode', mode);
66
67
  if (exchange)
67
68
  qs.set('exchange', exchange);
68
- return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}`);
69
+ return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}`, {
70
+ // Reconcile-sweep read, NOT on the agent decision path — keep the
71
+ // write-grade retry budget: a null here skips a whole reconcile pass.
72
+ maxAttempts: this.opts.maxAttempts,
73
+ deadlineMs: Number.POSITIVE_INFINITY,
74
+ });
69
75
  }
70
76
  /** Read endpoint for the Phase 1 self-reflection feature. Awaited.
71
77
  * Returns null on terminal/retry-exhausted failure (caller logs + degrades). */
@@ -246,12 +252,24 @@ export class PositionDecisionsClient {
246
252
  logger.error(TAG, `POST ${url} dropped after ${this.opts.maxAttempts} attempts. userId=${userId}.`);
247
253
  return null;
248
254
  }
249
- async runGetReturning(userId, path) {
255
+ /** Decision-path GET budget. Every read caller degrades to empty/null on
256
+ * failure, so burning the write-grade budget (4 × 10s + backoff ≈ 42s
257
+ * worst case) of the agent's turn to reach an optional result is pure
258
+ * heartbeat latency. Background reads (reconcile sweep) override per call. */
259
+ static READ_MAX_ATTEMPTS = 2;
260
+ static READ_DEADLINE_MS = 8_000;
261
+ async runGetReturning(userId, path, budget) {
250
262
  const url = `${this.opts.baseUrl}${path}`;
251
- for (let attempt = 1; attempt <= this.opts.maxAttempts; attempt++) {
263
+ const maxAttempts = budget?.maxAttempts ??
264
+ Math.min(this.opts.maxAttempts, PositionDecisionsClient.READ_MAX_ATTEMPTS);
265
+ const deadlineAt = Date.now() + (budget?.deadlineMs ?? PositionDecisionsClient.READ_DEADLINE_MS);
266
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
267
+ const remainingMs = deadlineAt - Date.now();
268
+ if (remainingMs <= 0)
269
+ break;
252
270
  try {
253
271
  const ac = new AbortController();
254
- const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
272
+ const tid = setTimeout(() => ac.abort(), Math.min(this.opts.requestTimeoutMs, remainingMs));
255
273
  let res;
256
274
  try {
257
275
  res = await this.opts.fetchImpl(url, {
@@ -285,16 +303,16 @@ export class PositionDecisionsClient {
285
303
  logger.warn(TAG, `GET ${url} → ${res.status} (terminal, dropped). userId=${userId} body=${errBody.slice(0, 200)}`);
286
304
  return null;
287
305
  }
288
- logger.warn(TAG, `GET ${url} → ${res.status} (attempt ${attempt}/${this.opts.maxAttempts}).`);
306
+ logger.warn(TAG, `GET ${url} → ${res.status} (attempt ${attempt}/${maxAttempts}).`);
289
307
  }
290
308
  catch (err) {
291
- logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${this.opts.maxAttempts}): ${formatError(err)}`);
309
+ logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${maxAttempts}): ${formatError(err)}`);
292
310
  }
293
- if (attempt < this.opts.maxAttempts) {
311
+ if (attempt < maxAttempts && Date.now() < deadlineAt) {
294
312
  await this.sleepBackoff(attempt);
295
313
  }
296
314
  }
297
- logger.error(TAG, `GET ${url} failed after ${this.opts.maxAttempts} attempts. userId=${userId}.`);
315
+ logger.error(TAG, `GET ${url} failed after ${maxAttempts} attempt(s)/deadline. userId=${userId}.`);
298
316
  return null;
299
317
  }
300
318
  async sleepBackoff(attempt) {