@reefclaw/openclaw-plugin 0.1.13 → 0.1.15

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 (71) hide show
  1. package/bridge/bridge.d.ts +20 -5
  2. package/bridge/bridge.js +29 -14
  3. package/bridge/config.js +6 -0
  4. package/bridge/gateway/gateway-config.d.ts +16 -5
  5. package/bridge/gateway/gateway-config.js +68 -12
  6. package/bridge/gateway/gateway-ws-client.d.ts +4 -1
  7. package/bridge/gateway/gateway-ws-client.js +41 -11
  8. package/bridge/gateway/poller.js +18 -8
  9. package/bridge/providers/emergency-commands.d.ts +9 -1
  10. package/bridge/providers/emergency-commands.js +38 -1
  11. package/bridge/providers/gateway.d.ts +51 -1
  12. package/bridge/providers/gateway.js +209 -22
  13. package/bridge/providers/onboarding-commands.d.ts +11 -0
  14. package/bridge/providers/onboarding-commands.js +5 -5
  15. package/bridge/providers/risk-calculator.d.ts +61 -2
  16. package/bridge/providers/risk-calculator.js +92 -20
  17. package/bridge/utils/skill-signing.js +8 -3
  18. package/ccxt/binance-public.d.ts +17 -5
  19. package/ccxt/binance-public.js +31 -3
  20. package/config/operator-provenance.d.ts +6 -0
  21. package/config/operator-provenance.js +50 -0
  22. package/config/plugin-config-io.d.ts +15 -1
  23. package/config/plugin-config-io.js +29 -0
  24. package/exchange-adapter.d.ts +13 -0
  25. package/index.js +230 -176
  26. package/ingest/event-loop-monitor.d.ts +22 -0
  27. package/ingest/event-loop-monitor.js +190 -0
  28. package/ingest/position-auto-capture.d.ts +5 -0
  29. package/ingest/position-auto-capture.js +14 -5
  30. package/ingest/readiness-reporter.d.ts +26 -6
  31. package/ingest/readiness-reporter.js +137 -9
  32. package/ingest/skill-version-reader.d.ts +16 -0
  33. package/ingest/skill-version-reader.js +64 -0
  34. package/live/approval-lifecycle.d.ts +30 -0
  35. package/live/approval-lifecycle.js +80 -0
  36. package/live/bracket-types.d.ts +9 -0
  37. package/live/live-adapter.d.ts +0 -1
  38. package/live/user-data-stream.js +10 -2
  39. package/onboarding/runtime.d.ts +34 -1
  40. package/onboarding/runtime.js +56 -5
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +6 -5
  43. package/risk/pre-trade-check.js +18 -5
  44. package/simulator/exchange-simulator.d.ts +45 -2
  45. package/simulator/exchange-simulator.js +96 -4
  46. package/simulator/types.d.ts +17 -0
  47. package/skills/reefclaw/SKILL.md +6 -11
  48. package/strategy/condition-registry.js +9 -2
  49. package/strategy/evaluator.d.ts +5 -0
  50. package/tools/attach-brackets.js +50 -1
  51. package/tools/cancel-all-orders.js +9 -1
  52. package/tools/create-order.js +18 -1
  53. package/tools/get-bracket-config.d.ts +21 -2
  54. package/tools/get-bracket-config.js +18 -2
  55. package/tools/set-trading-mode.js +6 -3
  56. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +25 -1
  57. package/venues/hyperliquid/hl-bracket-coordinator.js +57 -0
  58. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  59. package/venues/hyperliquid/hl-brackets.js +45 -13
  60. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  61. package/venues/hyperliquid/hl-fill-ingest.js +88 -0
  62. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  63. package/venues/hyperliquid/hl-live-adapter.js +116 -7
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
  68. package/venues/registry.js +8 -7
  69. package/wave9/paper-admission-guard.d.ts +12 -1
  70. package/wave9/paper-admission-guard.js +12 -1
  71. package/scripts/assemble.mjs +0 -130
@@ -6,7 +6,7 @@
6
6
  // via SimulationConfig and OrderBookDepth.
7
7
  import { EventEmitter } from 'node:events';
8
8
  import { randomUUID } from 'node:crypto';
9
- import { logger } from '../logger.js';
9
+ import { logger, formatError } from '../logger.js';
10
10
  import { MAX_TRADE_HISTORY, DEFAULT_SIMULATION_CONFIG } from './types.js';
11
11
  import { fillMarketOrder, fillLimitOrder, parseSymbol } from './fill-engine.js';
12
12
  import { updateMfe } from '../mfe.js';
@@ -15,6 +15,9 @@ const TAG = 'simulator';
15
15
  export class ExchangeSimulator extends EventEmitter {
16
16
  state;
17
17
  lastTicker = new Map();
18
+ /** Symbols with a take-profit close in flight — suppresses a re-entrant
19
+ * tick firing a second close on the same position. */
20
+ takeProfitPending = new Set();
18
21
  lastOrderBook = new Map();
19
22
  simulationConfig;
20
23
  /** Metadata for pending limit orders, keyed by order ID. Cleaned up on fill/cancel. */
@@ -247,7 +250,12 @@ export class ExchangeSimulator extends EventEmitter {
247
250
  return this.lastOrderBook.get(symbol);
248
251
  }
249
252
  // ---- Write operations (for tools) ----
250
- createOrder(symbol, side, type, amount, price, metadata) {
253
+ createOrder(symbol, side, type, amount, price, metadata,
254
+ /** Paper-only market-fill price override (take-profit leg). When set, the
255
+ * market branch prices off THIS instead of the last tick, and skips the
256
+ * stale-quote guard — the caller supplied the price, so quote age is
257
+ * irrelevant, and a protective exit must never be blocked (issue #202). */
258
+ referencePrice) {
251
259
  // ---- Startup trade lockout ----
252
260
  // Block trades during the first 15s after gateway restart IF there were
253
261
  // existing positions at startup. This prevents stale agent sessions from
@@ -281,6 +289,10 @@ export class ExchangeSimulator extends EventEmitter {
281
289
  createdAt: now,
282
290
  };
283
291
  if (type === 'market') {
292
+ // Explicit fill price (take-profit leg) — see the param doc above.
293
+ if (referencePrice !== undefined && Number.isFinite(referencePrice) && referencePrice > 0) {
294
+ return this.executeMarketFill(order, referencePrice, metadata);
295
+ }
284
296
  // Market orders fill immediately at current price
285
297
  const ticker = this.lastTicker.get(symbol);
286
298
  if (!ticker) {
@@ -345,7 +357,13 @@ export class ExchangeSimulator extends EventEmitter {
345
357
  }
346
358
  return cancelled.map(o => this.toCcxtOrder(o));
347
359
  }
348
- closePosition(symbol, closeReason) {
360
+ /**
361
+ * @param referencePrice Paper-only fill-price override. Used by the
362
+ * take-profit leg to fill AT the target level instead of the (possibly
363
+ * gapped-past) tick price — see `checkTakeProfitLegs`. Omitted everywhere
364
+ * else, which keeps the normal market-close path byte-identical.
365
+ */
366
+ closePosition(symbol, closeReason, referencePrice) {
349
367
  const position = this.state.positions.find(p => p.symbol === symbol);
350
368
  if (!position) {
351
369
  throw new Error(`No open position for ${symbol}`);
@@ -359,7 +377,7 @@ export class ExchangeSimulator extends EventEmitter {
359
377
  }
360
378
  // Create opposing market order to close the position
361
379
  const closeSide = position.side === 'long' ? 'sell' : 'buy';
362
- return this.createOrder(symbol, closeSide, 'market', position.quantity);
380
+ return this.createOrder(symbol, closeSide, 'market', position.quantity, undefined, undefined, referencePrice);
363
381
  }
364
382
  /** Paper-only: move an open position's MUTABLE protective levels (stopPrice /
365
383
  * targetPrice) in place and persist, WITHOUT the close+reopen round-trip
@@ -385,6 +403,10 @@ export class ExchangeSimulator extends EventEmitter {
385
403
  updateTicker(ticker) {
386
404
  this.lastTicker.set(ticker.symbol, ticker);
387
405
  this.refreshMfeForSymbol(ticker.symbol, ticker.last);
406
+ // MFE is refreshed FIRST so the peak this tick reached is recorded before a
407
+ // target close reads it — otherwise every TP exit would understate its own
408
+ // MFE and skew the capture-ratio analysis.
409
+ this.checkTakeProfitLegs(ticker.symbol, ticker.last);
388
410
  // Check if any pending limit orders should fill
389
411
  const toFill = [];
390
412
  const remaining = [];
@@ -429,6 +451,76 @@ export class ExchangeSimulator extends EventEmitter {
429
451
  getLastTicker(symbol) {
430
452
  return this.lastTicker.get(symbol);
431
453
  }
454
+ /**
455
+ * Take-profit legs — the paper analog of the exchange-native
456
+ * `TAKE_PROFIT_MARKET` order live attaches at entry.
457
+ *
458
+ * ★ Why this exists: paper STORED `metadata.targetPrice` and surfaced it
459
+ * (chart line, positions table) but nothing ever closed on it, so the TP was
460
+ * a drawing rather than an order. Every paper winner ran straight past its
461
+ * exit — observed live 2026-07-27 on an OP/USDT short that reached +2.07R
462
+ * against a 1.0R target. That made paper the odd one out of three: the
463
+ * BACKTEST exits at target (`backtest/engine.ts` exitReason 'target') and
464
+ * LIVE exits at target (Binance TP_MARKET leg / HL coordinator TP leg), so
465
+ * a strategy forward-validated on paper was being measured on a book that
466
+ * let every winner run.
467
+ *
468
+ * Fill convention: the TARGET LEVEL is the decision price, and the normal
469
+ * realistic-fill engine applies its own adverse slippage around it (book
470
+ * VWAP + vol factor + taker fee) — the same model every other paper fill
471
+ * uses, rather than a second hand-rolled slippage constant. When a tick gaps
472
+ * past the target we deliberately do NOT credit the gap: filling at the
473
+ * level is worse for us than filling at the gapped tick, so this stays
474
+ * conservative against both the backtest and a real TP_MARKET (which would
475
+ * fill at the gapped price).
476
+ *
477
+ * Stops stay with the PositionWatcher: it is the safety floor and re-homing
478
+ * it is a separate, riskier change. A single tick can only breach one leg
479
+ * (stop and target sit on opposite sides of entry), so there is no
480
+ * stop-vs-target ordering ambiguity to resolve here.
481
+ */
482
+ checkTakeProfitLegs(symbol, price) {
483
+ if (!Number.isFinite(price) || price <= 0)
484
+ return;
485
+ // Snapshot: closing mutates state.positions mid-iteration.
486
+ const candidates = this.state.positions.filter((p) => p.symbol === symbol);
487
+ for (const position of candidates) {
488
+ const target = position.metadata?.targetPrice;
489
+ if (target === undefined || !Number.isFinite(target) || target <= 0)
490
+ continue;
491
+ const breached = position.side === 'long' ? price >= target : price <= target;
492
+ if (!breached)
493
+ continue;
494
+ // Guard against a re-entrant tick firing a second close on the same
495
+ // symbol while the first is still settling.
496
+ if (this.takeProfitPending.has(symbol))
497
+ continue;
498
+ this.takeProfitPending.add(symbol);
499
+ try {
500
+ logger.info(TAG, `TARGET REACHED: ${symbol} ${position.side} price=${price} target=${target} — closing (exchange_target)`);
501
+ // Target = decision price; the fill engine adds realistic adverse
502
+ // slippage on top (see the fill-convention note above).
503
+ const order = this.closePosition(symbol, 'exchange_target', target);
504
+ this.emit('target_closed', {
505
+ symbol,
506
+ side: position.side,
507
+ targetPrice: target,
508
+ markPrice: price,
509
+ fillPrice: typeof order.average === 'number' ? order.average : target,
510
+ quantity: position.quantity,
511
+ order,
512
+ });
513
+ }
514
+ catch (err) {
515
+ // Never let a failed protective close kill the tick loop — the next
516
+ // tick retries, and the position is still visible to the agent.
517
+ logger.error(TAG, `Target close failed for ${symbol}: ${formatError(err)}`);
518
+ }
519
+ finally {
520
+ this.takeProfitPending.delete(symbol);
521
+ }
522
+ }
523
+ }
432
524
  /** Walk every position for `symbol` and refresh MFE / give-back from the
433
525
  * latest mark. Idempotent — pure update of `metadata.mfePeakPrice` (only
434
526
  * ratchets favourably) plus derived `mfeR` and `giveBackRatio`. Safe to
@@ -1,3 +1,4 @@
1
+ import type { CcxtOrder } from '../types.js';
1
2
  export interface FillError {
2
3
  orderId: string;
3
4
  symbol: string;
@@ -76,6 +77,22 @@ export interface ExecutionStats {
76
77
  export interface Wallet {
77
78
  [currency: string]: CurrencyBalance;
78
79
  }
80
+ /** Emitted when the paper take-profit leg fires (`ExchangeSimulator`
81
+ * 'target_closed'). The live analog is an exchange-native TAKE_PROFIT_MARKET
82
+ * fill; index.ts journals this the same way it journals a stop-watcher close,
83
+ * so a target exit can never leave a phantom-open journal row (issue #199). */
84
+ export interface TargetClosedEvent {
85
+ symbol: string;
86
+ side: 'long' | 'short';
87
+ /** The pinned target level that was breached. */
88
+ targetPrice: number;
89
+ /** The tick price that breached it (may have gapped past the target). */
90
+ markPrice: number;
91
+ /** Where we actually filled — the target level with adverse slippage. */
92
+ fillPrice: number;
93
+ quantity: number;
94
+ order: CcxtOrder;
95
+ }
79
96
  export interface CurrencyBalance {
80
97
  total: number;
81
98
  available: number;
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: reefclaw
3
- version: 0.0.6
3
+ version: 0.0.7
4
4
  description: ReefClaw trading control room — bootstrap (connects your agent; full trading instructions arrive automatically after first connect)
5
5
  author: ReefClaw
6
6
  homepage: https://reefclaw.com
@@ -24,18 +24,13 @@ config:
24
24
  description: ReefClaw relay WebSocket URL. Defaults to the production relay when omitted.
25
25
  ---
26
26
 
27
- <!-- BOOTSTRAP SKILL.md — this is the thin installer copy shipped in the
28
- @reefclaw/connect npm package. It carries ONLY the config schema and the
29
- connect instructions. The FULL trading instructions (versioned 2.x.y) are
30
- delivered automatically by the ReefClaw connector minutes after the first
31
- successful connect, over the authenticated update channel — they replace
32
- this file in place (a 0.0.x version always loses the semver comparison).
33
- The "Connecting (first run)" section below MUST stay in sync with the
34
- same section in skill/SKILL.md. -->
35
-
36
27
  # ReefClaw — Connect Your Agent (Bootstrap)
37
28
 
38
- You are being connected to **ReefClaw**, a supervised trading control room. Your operator watches everything you do on the ReefClaw dashboard. This bootstrap file only covers **connecting**; your full trading instructions arrive automatically a few minutes after the connection succeeds.
29
+ ## About this file
30
+
31
+ This is the thin BOOTSTRAP copy shipped in the `@reefclaw/connect` npm package. It covers **installing the ReefClaw plugin and connecting to the user's ReefClaw account** — nothing else. The FULL trading instructions (versioned 2.x.y) are delivered automatically by the ReefClaw connector minutes after the first successful connect, over the authenticated update channel — they replace this file in place (a 0.0.x version always loses the semver comparison). Maintainers: the "Connecting (first run)" section below MUST stay in sync with the same section in `skill/SKILL.md`.
32
+
33
+ You are being connected to **ReefClaw**, a supervised trading control room. Your operator watches everything you do on the ReefClaw dashboard. Some steps below change host configuration or install software; they exist solely to complete the setup **the user initiated by pasting their connect message**. Tell the user what each step changes before you do it, do it only for that user, and revert any temporary permission (like elevated exec) as soon as the install completes.
39
34
 
40
35
  **Do not attempt to trade before the full instructions arrive.** If trading tools are visible but this file is still a bootstrap version (0.0.x), the connection has not completed — finish connecting first.
41
36
 
@@ -36,20 +36,27 @@ register('oi_slope', (fact) => ({
36
36
  name: 'oi_slope',
37
37
  met: fact.conditions.oi_slope_up ?? false,
38
38
  }));
39
- // 5. no_liquidation_cluster — always passes in scan context (no live data)
39
+ // 5. no_liquidation_cluster — FAIL-OPEN PLACEHOLDER in scan context: the
40
+ // fact-computer ships no liquidation data, so this preview cannot evaluate
41
+ // it. `note` marks the gap explicitly (never a silent pass); the
42
+ // authoritative evaluation runs in the central signal engine, which reads the
43
+ // live liquidation-levels feed.
40
44
  register('no_liquidation_cluster', () => ({
41
45
  name: 'no_liquidation_cluster',
42
46
  met: true,
47
+ note: 'not evaluated in scan preview (no liquidation data in facts) — authoritative check runs in the signal engine',
43
48
  }));
44
49
  // 6. price_sweep
45
50
  register('price_sweep', (fact) => ({
46
51
  name: 'price_sweep',
47
52
  met: (fact.conditions.price_sweep_high ?? false) || (fact.conditions.price_sweep_low ?? false),
48
53
  }));
49
- // 7. liquidations_at_sweep — always passes in scan context
54
+ // 7. liquidations_at_sweep — FAIL-OPEN PLACEHOLDER in scan context, same gap
55
+ // and same note contract as no_liquidation_cluster above.
50
56
  register('liquidations_at_sweep', () => ({
51
57
  name: 'liquidations_at_sweep',
52
58
  met: true,
59
+ note: 'not evaluated in scan preview (no liquidation data in facts) — authoritative check runs in the signal engine',
53
60
  }));
54
61
  // 8. order_flow_absorption
55
62
  register('order_flow_absorption', (fact) => ({
@@ -45,6 +45,11 @@ export interface ConditionEvalResult {
45
45
  name: string;
46
46
  met: boolean;
47
47
  value?: number;
48
+ /** Set when `met` is a fail-open placeholder rather than a real evaluation
49
+ * (e.g. the scan facts carry no data for this condition). Surfaces the gap
50
+ * to any consumer instead of letting a pass silently impersonate a check —
51
+ * the authoritative evaluation runs in the central signal engine. */
52
+ note?: string;
48
53
  }
49
54
  export interface StrategyEvalResult {
50
55
  strategy: string;
@@ -323,13 +323,28 @@ async function attachBracketsHl(args, adapter) {
323
323
  let clearedStaleLedgerRow = false;
324
324
  if (existing && !isTerminalBracketState(existing.state)) {
325
325
  let cls = 'unknown';
326
+ // Per-LEG liveness (audit 2026-07-26 F4): "either cid survives ⇒ live"
327
+ // let a TP-only position no-op as "already protected" indefinitely while
328
+ // its stop was gone. A registered leg positively absent from a NON-EMPTY
329
+ // order set is a protection gap this tool must repair, not paper over.
330
+ const missingLegs = [];
326
331
  const hasCids = Boolean(existing.slCid || existing.tpCid);
327
332
  if (hasCids) {
328
333
  try {
329
334
  const open = await adapter.getOpenOrders(position.symbol);
330
335
  const liveCids = new Set(open.map(o => o.clientOrderId).filter(Boolean));
331
- if ((existing.slCid && liveCids.has(existing.slCid)) || (existing.tpCid && liveCids.has(existing.tpCid))) {
336
+ const stopLive = Boolean(existing.slCid && liveCids.has(existing.slCid));
337
+ const tpLive = Boolean(existing.tpCid && liveCids.has(existing.tpCid));
338
+ if (stopLive || tpLive) {
332
339
  cls = 'live';
340
+ if (open.length > 0) {
341
+ // Non-empty set = positive evidence for absence (d59e51b) — a
342
+ // registered-but-absent sibling is a repairable gap.
343
+ if (existing.slCid && !stopLive)
344
+ missingLegs.push('stop');
345
+ if (existing.tpCid && !tpLive)
346
+ missingLegs.push('target');
347
+ }
333
348
  }
334
349
  else if (open.length > 0) {
335
350
  cls = 'stale'; // non-empty set positively lacking our cids
@@ -351,6 +366,40 @@ async function attachBracketsHl(args, adapter) {
351
366
  if (cls === 'live') {
352
367
  if (pricesMatch(args.stop_price, existing.stopPrice)
353
368
  && pricesMatch(args.target_price, existing.targetPrice)) {
369
+ if (missingLegs.length > 0) {
370
+ // One registered leg is positively gone (a stop-less position is a
371
+ // safety-floor breach). Rebuild it at the registered price via the
372
+ // resize path — planResize submits ONLY the missing leg and leaves
373
+ // the healthy sibling untouched (F4).
374
+ try {
375
+ const coordinator = adapter.getHlBracketCoordinator();
376
+ await coordinator.resizeToPosition(position.symbol, contracts);
377
+ const healed = ledger.getBySymbol(position.symbol);
378
+ return {
379
+ ok: true,
380
+ symbol: position.symbol,
381
+ bracket_id: existing.bracketId,
382
+ entry_side: entrySide,
383
+ stop_price: existing.stopPrice,
384
+ target_price: existing.targetPrice,
385
+ sl_cid: healed?.slCid ?? existing.slCid,
386
+ tp_cid: healed?.tpCid ?? existing.tpCid,
387
+ attach_latency_ms: 0,
388
+ cancelled_stale_bracket_orders: 0,
389
+ cleared_stale_ledger_row: false,
390
+ attempts: 1,
391
+ idempotent_no_op: false,
392
+ note: `Missing ${missingLegs.join('+')} leg re-attached at the registered price(s); sibling leg untouched.`,
393
+ };
394
+ }
395
+ catch (err) {
396
+ return {
397
+ error: `Registered ${missingLegs.join('+')} leg is GONE from the exchange and the rebuild ` +
398
+ `failed (${formatError(err)}). The position is under-protected — retry attach_brackets ` +
399
+ `next heartbeat; the 60s truth sweep also retries. Do NOT record a protected review.`,
400
+ };
401
+ }
402
+ }
354
403
  return {
355
404
  ok: true,
356
405
  symbol: position.symbol,
@@ -1,5 +1,13 @@
1
1
  // Tool: cancel_all_orders — cancel all open orders (paper or live)
2
- // NO readiness gate — emergency control (kill switch), must always work.
2
+ //
3
+ // NO readiness gate and NO confirmation prompt — BY DESIGN, not an oversight:
4
+ // this is the safety floor's kill-switch primitive ("Hard controls NEVER
5
+ // disabled or hidden"). Any gate added here becomes a failure mode of the
6
+ // emergency path itself — a wedged confirmation would strand live orders
7
+ // during the exact incident the kill switch exists for. Risk-reducing only:
8
+ // it cancels WORKING orders; the adapter layer preserves protective bracket
9
+ // legs (parseBracketCid skip in cancelAllOrders) so positions are never left
10
+ // naked, and it never opens or increases exposure.
3
11
  export async function cancelAllOrdersTool(args, deps) {
4
12
  const cancel = () => deps.adapter.cancelAllOrders(args.symbol);
5
13
  return deps.adapter.isLive && deps.operationLock
@@ -1008,11 +1008,28 @@ export async function createOrderTool(args, deps) {
1008
1008
  }
1009
1009
  // Bracket enforcement only applies in live mode when the feature is enabled.
1010
1010
  // Paper mode uses the stop-watcher and doesn't care about these flags.
1011
+ //
1012
+ // ★ `brackets.mode` is a BINANCE-only knob — it is only ever passed to
1013
+ // LiveAdapter, and it defaults to 'off'. Gating solely on it meant an HL
1014
+ // live rig skipped the mandatory-stop check entirely: a stopless entry
1015
+ // passed the gate, and HL only attaches legs when stop/target metadata is
1016
+ // present (`wireBracketsAfterSubmit`), so the position went on the book
1017
+ // NAKED. Venues that always enforce brackets declare it on the adapter.
1011
1018
  let bracketEnforcement;
1012
- if (deps.adapter.isLive && bracketsEnabled(loadBracketMode())) {
1019
+ if (deps.adapter.isLive && (deps.adapter.bracketsAlwaysEnforced || bracketsEnabled(loadBracketMode()))) {
1013
1020
  bracketEnforcement = wave9Claimed
1014
1021
  ? { requireStopLoss: true, requireTakeProfit: false }
1015
1022
  : loadBracketRequirements();
1023
+ // On a venue-enforced adapter the stop requirement is NOT operator-
1024
+ // waivable: `requireStopLoss: false` in plugin-config is a Binance-era
1025
+ // toggle whose documented risk assumed a watcher fallback existed. HL has
1026
+ // none — a waved-through stopless entry would sit naked. This also keeps
1027
+ // the gate consistent with get_bracket_config, which reports
1028
+ // requireStopLoss=true for venue-enforced adapters. The TP flag stays
1029
+ // operator-controlled (stop-only "let winners run" is legitimate).
1030
+ if (deps.adapter.bracketsAlwaysEnforced) {
1031
+ bracketEnforcement = { ...bracketEnforcement, requireStopLoss: true };
1032
+ }
1016
1033
  }
1017
1034
  const riskCheck = preTradeRiskCheck(proposed, portfolio, getDefaultPreTradeLimits(), {
1018
1035
  volFactor: !deps.adapter.isLive ? deps.adapter.getSimulator().getVolFactor() : 1.0,
@@ -3,9 +3,28 @@ export interface GetBracketConfigResult {
3
3
  mode: BracketMode;
4
4
  requireStopLoss: boolean;
5
5
  requireTakeProfit: boolean;
6
+ /** True when the mode reported above is the VENUE's unconditional
7
+ * enforcement rather than the `brackets.mode` config value. Lets the
8
+ * dashboard explain why the toggles are inert. */
9
+ venueEnforced?: boolean;
6
10
  }
7
- export declare function getBracketConfigTool(_args: Record<string, never>, deps?: {
11
+ export interface GetBracketConfigDeps {
8
12
  configPath?: string;
9
- }): GetBracketConfigResult | {
13
+ /** Active adapter. Its `bracketsAlwaysEnforced` capability overrides the
14
+ * config-file mode — see below. */
15
+ adapter?: {
16
+ readonly bracketsAlwaysEnforced?: boolean;
17
+ };
18
+ }
19
+ /**
20
+ * ★ `brackets.mode` in plugin-config.json is a BINANCE-only lifecycle knob: it
21
+ * is passed to `LiveAdapter` and nowhere else. Reporting it verbatim made a
22
+ * Hyperliquid rig — where `HlBracketCoordinator` attaches legs unconditionally
23
+ * and there is no watcher fallback — render "Brackets off" on a live dashboard
24
+ * whose every position was in fact bracketed. A protection indicator that
25
+ * under-reports is exactly as dangerous as one that over-reports, so the
26
+ * effective venue behaviour wins over the stale config value.
27
+ */
28
+ export declare function getBracketConfigTool(_args: Record<string, never>, deps?: GetBracketConfigDeps): GetBracketConfigResult | {
10
29
  error: string;
11
30
  };
@@ -6,13 +6,29 @@
6
6
  import { readPluginConfig } from '../config/plugin-config-io.js';
7
7
  import { getBracketMode, getBracketRequirements } from '../config/brackets-config.js';
8
8
  import { formatError } from '../logger.js';
9
+ /**
10
+ * ★ `brackets.mode` in plugin-config.json is a BINANCE-only lifecycle knob: it
11
+ * is passed to `LiveAdapter` and nowhere else. Reporting it verbatim made a
12
+ * Hyperliquid rig — where `HlBracketCoordinator` attaches legs unconditionally
13
+ * and there is no watcher fallback — render "Brackets off" on a live dashboard
14
+ * whose every position was in fact bracketed. A protection indicator that
15
+ * under-reports is exactly as dangerous as one that over-reports, so the
16
+ * effective venue behaviour wins over the stale config value.
17
+ */
9
18
  export function getBracketConfigTool(_args, deps) {
10
19
  try {
11
20
  const cfg = readPluginConfig(deps?.configPath);
12
- const mode = getBracketMode(cfg);
13
21
  const req = getBracketRequirements(cfg);
22
+ if (deps?.adapter?.bracketsAlwaysEnforced) {
23
+ return {
24
+ mode: 'enforce',
25
+ requireStopLoss: true,
26
+ requireTakeProfit: req.requireTakeProfit,
27
+ venueEnforced: true,
28
+ };
29
+ }
14
30
  return {
15
- mode,
31
+ mode: getBracketMode(cfg),
16
32
  requireStopLoss: req.requireStopLoss,
17
33
  requireTakeProfit: req.requireTakeProfit,
18
34
  };
@@ -4,9 +4,12 @@
4
4
  // the one-rung-at-a-time ladder. Requires valid exchange credentials for
5
5
  // any non-PAPER target. Rebuilds the adapter via runtime.reconnect().
6
6
  //
7
- // The agent should NOT call this tool; PR2 will enforce operator-only scope
8
- // in the skill. PR1 leaves the plugin-side handler functional but unguarded
9
- // because the caller chain is currently controlled end-to-end by the skill.
7
+ // GUARDED at the dispatch site (plugin/src/index.ts registration): every call
8
+ // must carry the `operator_token` provenance proof and is refused by
9
+ // verifyOperatorProvenance without it (audit 2026-07-26 F12). The agent cannot
10
+ // supply that token — chat redaction strips rc_* tokens — so only the
11
+ // dashboard operator path reaches this handler. This module stays guard-free
12
+ // by design: the check lives once, at registration, for all operator tools.
10
13
  import { readPluginConfig } from '../config/plugin-config-io.js';
11
14
  import { validateModeTransition, modeRequiresCredentials } from '../onboarding/mode-ladder.js';
12
15
  import { parseVenue } from '../venues/registry.js';
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import type { CcxtOrder, CcxtPosition } from '../../types.js';
3
3
  import type { CloseReason } from '../../simulator/types.js';
4
- import type { BracketId, BracketRequest, BracketState } from '../../live/bracket-types.js';
4
+ import type { BracketId, BracketLedgerEntry, BracketRequest, BracketState } from '../../live/bracket-types.js';
5
5
  import type { BracketLedger } from '../../live/bracket-ledger.js';
6
6
  import type { HlOrderUpdateEvent } from './hl-user-stream.js';
7
7
  /** Narrow execution surface the coordinator needs — the HyperliquidLiveAdapter
@@ -24,6 +24,11 @@ export interface HlBracketExecutor {
24
24
  symbol: string;
25
25
  positionSide: 'long' | 'short';
26
26
  positionSize: number;
27
+ /** Ledger-registered prices — lets resize REBUILD a vanished leg (F4). */
28
+ registeredPrices?: {
29
+ stop?: number;
30
+ target?: number;
31
+ };
27
32
  }): Promise<{
28
33
  resized: boolean;
29
34
  slCid?: string;
@@ -88,6 +93,25 @@ export declare class HlBracketCoordinator extends EventEmitter {
88
93
  * fill signal against a non-terminal row is a warned NO-OP, never a fresh
89
94
  * bracketId that orphans the live legs' ledger identity. */
90
95
  registerEntry(req: BracketRequest, bracketId: BracketId, entryCid: string): void;
96
+ /** ★ Audit 2026-07-26 F5 — track a SECOND entry order placed against a live
97
+ * bracket row (a scale-in, or another entry while the first still rests).
98
+ *
99
+ * Each submission gets a fresh cloid, but the row keeps the ORIGINAL
100
+ * `entryCid`. The user-stream fill handler matches fills to rows by cid, so
101
+ * an unrecorded cid meant the later fill matched NOTHING: no attach, no
102
+ * resize. On HL that is naked exposure, not cosmetic drift — legs are FIXED
103
+ * SIZE (T-2), so the added contracts stayed unprotected until the 60s
104
+ * truth-check sweep happened to catch them.
105
+ *
106
+ * Idempotent; a terminal/absent row or a repeat of the primary cid is a
107
+ * no-op. Recording is deliberately CHEAP and local — the sweep remains the
108
+ * backstop, this just stops it being the only line of defence. */
109
+ registerAdditionalEntryCid(symbol: string, entryCid: string): void;
110
+ /** The bracket row a user-stream fill belongs to: the primary `entryCid` OR
111
+ * any cid recorded by `registerAdditionalEntryCid`. Terminal rows never
112
+ * match. Single home for the matching rule so the adapter's fill handler
113
+ * and the ledger can never disagree about what "our entry" means. */
114
+ findRowByEntryCid(cloid: string | undefined | null): BracketLedgerEntry | undefined;
91
115
  /** Attach both legs (ONE batched signed action) with retries. Idempotent on
92
116
  * a non-pending row. On exhaustion: ledger 'failed' + attach_failed event —
93
117
  * the ADAPTER escalates to auto-flatten (it owns closePosition). */
@@ -32,6 +32,10 @@ import { parseHlBracketCloid } from './hl-cloid.js';
32
32
  const TAG = 'hl-bracket-coordinator';
33
33
  const DEFAULT_MAX_ATTEMPTS = 3;
34
34
  const DEFAULT_BACKOFF = (attempt) => (attempt === 1 ? 1_000 : 2_000);
35
+ /** Cap on `extraEntryCids` so a position scaled many times cannot grow its
36
+ * ledger row without bound. Oldest dropped — a cid that old has either filled
37
+ * (row already resized) or been cancelled. */
38
+ const MAX_EXTRA_ENTRY_CIDS = 20;
35
39
  const TERMINAL_STATES = new Set([
36
40
  'triggered_sl',
37
41
  'triggered_tp',
@@ -109,6 +113,12 @@ export class HlBracketCoordinator extends EventEmitter {
109
113
  logger.warn(TAG, `registerEntry(${req.symbol}): non-terminal row exists (state=${existing.state}, ` +
110
114
  `bracketId=${existing.bracketId}) — duplicate fill signal, NOT clobbering ` +
111
115
  `(anti-clobber contract; scale-ins go through resizeToPosition)`);
116
+ // ★ F5: not clobbering the row is right — DROPPING the new order's cloid
117
+ // was not. A duplicate signal carrying a DIFFERENT cid is a second order
118
+ // against the same position (a resting scale-in, or a second entry while
119
+ // the first is still pending); its fill has to find this row or nothing
120
+ // attaches/resizes for it.
121
+ this.registerAdditionalEntryCid(req.symbol, entryCid);
112
122
  return;
113
123
  }
114
124
  if (existing)
@@ -129,6 +139,49 @@ export class HlBracketCoordinator extends EventEmitter {
129
139
  ts: new Date(this.now()).toISOString(),
130
140
  });
131
141
  }
142
+ /** ★ Audit 2026-07-26 F5 — track a SECOND entry order placed against a live
143
+ * bracket row (a scale-in, or another entry while the first still rests).
144
+ *
145
+ * Each submission gets a fresh cloid, but the row keeps the ORIGINAL
146
+ * `entryCid`. The user-stream fill handler matches fills to rows by cid, so
147
+ * an unrecorded cid meant the later fill matched NOTHING: no attach, no
148
+ * resize. On HL that is naked exposure, not cosmetic drift — legs are FIXED
149
+ * SIZE (T-2), so the added contracts stayed unprotected until the 60s
150
+ * truth-check sweep happened to catch them.
151
+ *
152
+ * Idempotent; a terminal/absent row or a repeat of the primary cid is a
153
+ * no-op. Recording is deliberately CHEAP and local — the sweep remains the
154
+ * backstop, this just stops it being the only line of defence. */
155
+ registerAdditionalEntryCid(symbol, entryCid) {
156
+ if (!entryCid)
157
+ return;
158
+ const row = this.ledger.getBySymbol(symbol);
159
+ if (!row || isTerminalBracketState(row.state))
160
+ return;
161
+ if (row.entryCid === entryCid)
162
+ return;
163
+ const current = row.extraEntryCids ?? [];
164
+ if (current.includes(entryCid))
165
+ return;
166
+ this.ledger.upsert({
167
+ ...row,
168
+ extraEntryCids: [...current, entryCid].slice(-MAX_EXTRA_ENTRY_CIDS),
169
+ });
170
+ logger.info(TAG, `${row.symbol}: tracking additional entry cid ${entryCid} against bracket ` +
171
+ `${row.bracketId} (state=${row.state}) — its fill must drive attach/resize (T-2)`);
172
+ }
173
+ /** The bracket row a user-stream fill belongs to: the primary `entryCid` OR
174
+ * any cid recorded by `registerAdditionalEntryCid`. Terminal rows never
175
+ * match. Single home for the matching rule so the adapter's fill handler
176
+ * and the ledger can never disagree about what "our entry" means. */
177
+ findRowByEntryCid(cloid) {
178
+ if (!cloid)
179
+ return undefined;
180
+ return this.ledger
181
+ .getAll()
182
+ .find((r) => !isTerminalBracketState(r.state) &&
183
+ (r.entryCid === cloid || r.extraEntryCids?.includes(cloid) === true));
184
+ }
132
185
  /** Attach both legs (ONE batched signed action) with retries. Idempotent on
133
186
  * a non-pending row. On exhaustion: ledger 'failed' + attach_failed event —
134
187
  * the ADAPTER escalates to auto-flatten (it owns closePosition). */
@@ -261,6 +314,10 @@ export class HlBracketCoordinator extends EventEmitter {
261
314
  symbol: entry.symbol,
262
315
  positionSide,
263
316
  positionSize: newPositionSize,
317
+ // F4: with the registered prices in hand, resize can REBUILD a leg that
318
+ // vanished entirely (e.g. a stripped stop) instead of no-oping while
319
+ // the coverage sweep warns forever.
320
+ registeredPrices: { stop: entry.stopPrice, target: entry.targetPrice },
264
321
  });
265
322
  if (res.resized) {
266
323
  this.ledger.markState(symbol, entry.state, {
@@ -87,6 +87,16 @@ export declare function planResize(args: {
87
87
  positionSide: 'long' | 'short';
88
88
  positionSize: number;
89
89
  liveLegs: LiveLeg[];
90
+ /** The prices REGISTERED at attach (the ledger row's stopPrice/targetPrice).
91
+ * ★ Audit 2026-07-26 F4: a role whose leg vanished entirely used to be
92
+ * "the attach path's job" — but no path owned it, so a stop-less position
93
+ * never self-healed. With the registered price in hand, resize REBUILDS
94
+ * the missing leg at the price the agent pinned; without one it cannot
95
+ * invent a level and leaves the role to the coverage alarms. */
96
+ registeredPrices?: {
97
+ stop?: number;
98
+ target?: number;
99
+ };
90
100
  }): ResizePlan;
91
101
  /** Is this exchange order one of OUR protective legs?
92
102
  *