@senpi/runtime 3.0.72 → 3.0.74

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 (83) hide show
  1. package/dist/cli/senpi-commands.d.ts.map +1 -1
  2. package/dist/cli/senpi-commands.js +6 -5
  3. package/dist/cli/senpi-commands.js.map +1 -1
  4. package/dist/deploy/deadlines.d.ts +61 -1
  5. package/dist/deploy/deadlines.d.ts.map +1 -1
  6. package/dist/deploy/deadlines.js +85 -2
  7. package/dist/deploy/deadlines.js.map +1 -1
  8. package/dist/deploy/funding.d.ts +38 -0
  9. package/dist/deploy/funding.d.ts.map +1 -1
  10. package/dist/deploy/funding.js +39 -0
  11. package/dist/deploy/funding.js.map +1 -1
  12. package/dist/deploy/job.d.ts.map +1 -1
  13. package/dist/deploy/job.js +7 -1
  14. package/dist/deploy/job.js.map +1 -1
  15. package/dist/deploy/messages.d.ts +603 -0
  16. package/dist/deploy/messages.d.ts.map +1 -0
  17. package/dist/deploy/messages.js +1388 -0
  18. package/dist/deploy/messages.js.map +1 -0
  19. package/dist/deploy/orchestrator.d.ts +8 -0
  20. package/dist/deploy/orchestrator.d.ts.map +1 -1
  21. package/dist/deploy/orchestrator.js +146 -1211
  22. package/dist/deploy/orchestrator.js.map +1 -1
  23. package/dist/deploy/package.d.ts +89 -0
  24. package/dist/deploy/package.d.ts.map +1 -1
  25. package/dist/deploy/package.js +213 -25
  26. package/dist/deploy/package.js.map +1 -1
  27. package/dist/deploy/proof-gate.d.ts +4 -0
  28. package/dist/deploy/proof-gate.d.ts.map +1 -1
  29. package/dist/deploy/proof-gate.js +4 -0
  30. package/dist/deploy/proof-gate.js.map +1 -1
  31. package/dist/deploy/recheck-commands.d.ts.map +1 -1
  32. package/dist/deploy/recheck-commands.js +21 -3
  33. package/dist/deploy/recheck-commands.js.map +1 -1
  34. package/dist/deploy/register.d.ts +2 -0
  35. package/dist/deploy/register.d.ts.map +1 -1
  36. package/dist/deploy/register.js +23 -6
  37. package/dist/deploy/register.js.map +1 -1
  38. package/dist/deploy/taxonomy.d.ts +19 -0
  39. package/dist/deploy/taxonomy.d.ts.map +1 -1
  40. package/dist/deploy/taxonomy.js +28 -0
  41. package/dist/deploy/taxonomy.js.map +1 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +230 -176
  44. package/dist/index.js.map +1 -1
  45. package/dist/runtime/env-resolve.d.ts +13 -0
  46. package/dist/runtime/env-resolve.d.ts.map +1 -1
  47. package/dist/runtime/env-resolve.js +14 -1
  48. package/dist/runtime/env-resolve.js.map +1 -1
  49. package/dist/runtime/runtime-schema.d.ts +100 -100
  50. package/dist/scanners/external/scaffold/preflight.py +39 -0
  51. package/dist/telemetry/metrics.d.ts +7 -0
  52. package/dist/telemetry/metrics.d.ts.map +1 -1
  53. package/dist/telemetry/metrics.js +7 -0
  54. package/dist/telemetry/metrics.js.map +1 -1
  55. package/dist/validate/codes.d.ts +10 -0
  56. package/dist/validate/codes.d.ts.map +1 -1
  57. package/dist/validate/codes.js +38 -1
  58. package/dist/validate/codes.js.map +1 -1
  59. package/dist/validate/live-stage.d.ts +8 -0
  60. package/dist/validate/live-stage.d.ts.map +1 -1
  61. package/dist/validate/live-stage.js +87 -18
  62. package/dist/validate/live-stage.js.map +1 -1
  63. package/dist/validate/package-gates-stage.d.ts +61 -0
  64. package/dist/validate/package-gates-stage.d.ts.map +1 -0
  65. package/dist/validate/package-gates-stage.js +215 -0
  66. package/dist/validate/package-gates-stage.js.map +1 -0
  67. package/dist/validate/run.d.ts +24 -1
  68. package/dist/validate/run.d.ts.map +1 -1
  69. package/dist/validate/run.js +51 -4
  70. package/dist/validate/run.js.map +1 -1
  71. package/dist/validate/taxonomy-finding.d.ts +22 -0
  72. package/dist/validate/taxonomy-finding.d.ts.map +1 -0
  73. package/dist/validate/taxonomy-finding.js +25 -0
  74. package/dist/validate/taxonomy-finding.js.map +1 -0
  75. package/dist/validate/universe-stage.d.ts.map +1 -1
  76. package/dist/validate/universe-stage.js +2 -20
  77. package/dist/validate/universe-stage.js.map +1 -1
  78. package/dist/validate/wallet-binding-stage.d.ts +44 -0
  79. package/dist/validate/wallet-binding-stage.d.ts.map +1 -0
  80. package/dist/validate/wallet-binding-stage.js +92 -0
  81. package/dist/validate/wallet-binding-stage.js.map +1 -0
  82. package/openclaw.plugin.json +1 -1
  83. package/package.json +1 -1
@@ -0,0 +1,1388 @@
1
+ /**
2
+ * Every refusal, warning and detail string the deploy verb emits — one producer per taxonomy code.
3
+ *
4
+ * Split out of `orchestrator.ts` verbatim: this module holds the rendering, `orchestrator.ts` holds
5
+ * the control flow that decides which of these to call. Nothing here reads the job's run state, which
6
+ * is why it could move at all — the handful of builders that DO take an `InstanceRun` stay beside it.
7
+ */
8
+ import { join, } from "node:path";
9
+ import { describeScannerEnabledOffense, lowercasePackageIdFix, sanitizeStrategyName, } from "./package.js";
10
+ import { CLOSING_STATUSES, isInitializing, } from "../senpi/strategy-status.js";
11
+ import { MIN_WALLET, budgetArg, resumeCommand, usd, } from "./funding.js";
12
+ import { deployPyValidateCommand, PROOF_ROUTE_NOTE, recheckBlock, validateCommand, } from "./recheck-commands.js";
13
+ import { INSTALL_DEADLINE_MS, INSTALL_INDETERMINATE_CODE, WALLET_INSTALL_IN_FLIGHT_CODE, } from "./deadlines.js";
14
+ /** The provenance clause every reconcile message carries, so a report never hides how it matched. */
15
+ export function matchedByClause(matchedBy) {
16
+ return matchedBy === "created-key" ? " (matched by journaled create key, verified against the backend)" : "";
17
+ }
18
+ /**
19
+ * The other live strategies that answered to the matched name and were dropped as another
20
+ * package's. A bind that ignored one is still a bind on a CONTESTED name — the collision outlives
21
+ * this deploy and surfaces again at the next `close.py`, rename, or status read — so the report
22
+ * says so rather than rendering a clean "matched by name". It is a note, not a warning: nothing
23
+ * here is this package's to act on, which is the same reason those rows were dropped.
24
+ */
25
+ export function foreignCollisionClause(ignoredForeign) {
26
+ if (ignoredForeign.length === 0)
27
+ return "";
28
+ const stamps = [...new Set(ignoredForeign.map((s) => s.skillName?.trim()).filter((v) => !!v))];
29
+ const whose = stamps.length > 0 ? stamps.map((s) => `"${s}"`).join(", ") : "another package";
30
+ const plural = ignoredForeign.length === 1 ? "y" : "ies";
31
+ return (` — note: ${ignoredForeign.length} other live strateg${plural} of the same name ` +
32
+ `${ignoredForeign.length === 1 ? "is" : "are"} stamped for ${whose} and ${ignoredForeign.length === 1 ? "was" : "were"} ` +
33
+ `left untouched`);
34
+ }
35
+ export function buildAmbiguousRefusal(count, matchedAs, instance) {
36
+ return (`[E_STATE_AMBIGUOUS_WALLETS] ${count} live strategies ${matchedAs} — cannot resolve ` +
37
+ `which wallet belongs to instance "${instance}". Read-only triage first (openclaw senpi status / ` +
38
+ `strategy_list), clear the ambiguity with the user. NEVER close or recreate to "start clean".`);
39
+ }
40
+ /**
41
+ * A single live name-match whose wallet address cannot be read. deploy.py refused this case rather
42
+ * than guessing, and the taxonomy's `E_STATE_AMBIGUOUS_WALLETS` row names it explicitly: the strategy
43
+ * may be funded and live, so neither adopting a blank address nor creating a second wallet is safe.
44
+ */
45
+ export function buildUnreadableWalletRefusal(strategyName, instance, strategyId) {
46
+ // An id the backend did not report is stated as unreadable, never rendered as the blank in
47
+ // "(id )" — the same rule the wallet address in this very refusal is subject to. A reader who
48
+ // sees an empty parenthetical cannot tell a missing id from a rendering bug.
49
+ const id = strategyId?.trim() ? `id ${strategyId.trim()}` : "id unreadable";
50
+ return (`[E_STATE_AMBIGUOUS_WALLETS] Live strategy "${strategyName}" (${id}) matches instance ` +
51
+ `"${instance}" but its wallet address is unreadable — cannot resolve which wallet this instance ` +
52
+ `owns, and it may be funded and live. Nothing was created or changed. Read-only triage first ` +
53
+ `(openclaw senpi status / strategy_list), clear the ambiguity with the user. NEVER close or recreate to ` +
54
+ `"start clean".`);
55
+ }
56
+ /**
57
+ * The `create: skipped` detail for an adopted wallet.
58
+ *
59
+ * When a budget was requested, this is the only place the report can say that it was **not**
60
+ * applied — deploy never adds funds to a wallet that already exists, and a silent skip would let an
61
+ * agent summarise "deployed spider with $500" over a wallet holding $100.
62
+ */
63
+ /**
64
+ * How a backend `totalFunded` read renders in prose.
65
+ *
66
+ * An unreadable figure is stated as unreadable, never as `$0.00`. Every sentence that quotes this
67
+ * number sits beside an instruction about money, and the two cases call for opposite next steps: a
68
+ * genuine zero says "there is nothing on this wallet", an unread one says "nobody knows yet".
69
+ */
70
+ export function fundedClause(amount) {
71
+ return amount === undefined ? "with no readable funded amount reported" : `funded ${usd(amount)}`;
72
+ }
73
+ /** `its $60.00` / `its balance` — the possessive form of the same rule. */
74
+ export function itsFunded(amount) {
75
+ return amount === undefined ? "its balance" : `its ${usd(amount)}`;
76
+ }
77
+ /**
78
+ * The rollback voice of {@link fundedClause}: `funded $250.00` / the unread form.
79
+ *
80
+ * Both sentences it feeds order money moves on the wallet they name — "closing it and returning the
81
+ * funds", "Reclaim the funds from THIS wallet" — so a `?? 0` here told the reader to reclaim from a
82
+ * wallet the same sentence called empty, which reads as "nothing to reclaim, skip it" over money
83
+ * that is genuinely out there. UNKNOWN, never $0.00.
84
+ */
85
+ export function rollbackFundedClause(amount) {
86
+ return amount === undefined
87
+ ? `funded an amount that was unreadable at deploy time (UNKNOWN, never zero — verify the ` +
88
+ `wallet's balance)`
89
+ : `funded ${usd(amount)}`;
90
+ }
91
+ /**
92
+ * A live name-match in a status that is neither ACTIVE nor on its way there — PAUSED, mid-teardown,
93
+ * or something this verb does not recognise.
94
+ *
95
+ * Polling these is the bug this builder exists to prevent: a PAUSED strategy never becomes ACTIVE on
96
+ * its own, so waiting on it burns the whole `--max-wait` and then reports the bounded-wait outcome
97
+ * — "re-run to resume, reconciliation waits for it to reach ACTIVE" — which is unactionable on a
98
+ * status that will never get there. The next step is computed from the status actually read,
99
+ * and the non-destructive option always leads.
100
+ */
101
+ export function buildNotDeployableRefusal(matched, instance, pkg, budget) {
102
+ const status = matched.status.toUpperCase();
103
+ const head = `[INVALID_REQUEST] Live strategy "${matched.strategyName}" (id ${matched.id}) matches instance ` +
104
+ `"${instance}" but its status is ${matched.status} — `;
105
+ const rerun = `re-run \`${resumeCommand(pkg.dir, budget)}\``;
106
+ if (status === "PAUSED") {
107
+ return (`${head}a paused strategy cannot be deployed onto, and it will not become ACTIVE on its own. ` +
108
+ `Nothing was created. Resume it first, then ${rerun}. If you meant to start over instead, tear ` +
109
+ `this one down with \`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\` (that returns its ` +
110
+ `funds) before re-running — never leave it paused and fund a second wallet beside it.`);
111
+ }
112
+ if (CLOSING_STATUSES.includes(status)) {
113
+ return (`${head}it is being torn down. Nothing was created. Wait for it to reach CLOSED (re-check with ` +
114
+ `\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`), then ${rerun} — the re-run will ` +
115
+ `create a fresh wallet once this one is gone.`);
116
+ }
117
+ return (`${head}deploy cannot proceed from that state and will not guess. Nothing was created. Read-only ` +
118
+ `triage first (openclaw senpi status / strategy_list) to find out why it is ${matched.status}, then ${rerun} ` +
119
+ `once it is ACTIVE. NEVER close or recreate to "start clean".`);
120
+ }
121
+ export function buildBudgetRequiredRefusal(wallets, pkg) {
122
+ return (`[INVALID_REQUEST] --budget is required to create ${wallets} wallet(s) ` +
123
+ `(min ${usd(MIN_WALLET)}/wallet). Nothing was created. Re-run: ${resumeCommand(pkg.dir)}`);
124
+ }
125
+ /**
126
+ * `--budget 0` is not "no budget": it parses, and without this gate `planFunding` floors every
127
+ * wallet to MIN_WALLET — funding money the user never asked to spend. `--budget ≤ $0` is also the
128
+ * taxonomy's rule-4 field case: its only known source in the field is a buggy computed hint, so the
129
+ * refusal teaches where the real fix lives instead of letting that loop run. The same gate exists
130
+ * at gateway intake (register.ts), keeping every caller of `runDeploy` covered.
131
+ */
132
+ export function buildBudgetNotPositiveRefusal(budget, wallets, pkg) {
133
+ return (`[INVALID_REQUEST] --budget must be a positive USD amount: ${usd(budget)} cannot fund ` +
134
+ `${wallets} wallet(s) (min ${usd(MIN_WALLET)}/wallet). Nothing was created. If a report ` +
135
+ `suggested \`--budget ${budgetArg(budget)}\`, that hint was a bug, not an instruction — a ` +
136
+ `sleeve declaring \`funding_share: 0\` is fixed by giving it a share in strategy.yaml, never ` +
137
+ `by re-deploying. Re-run: ${resumeCommand(pkg.dir)}`);
138
+ }
139
+ /** `scalp $12.00 (needs $13.50)` — both numbers, so the claim is checkable. */
140
+ function renderUnderfunded(under) {
141
+ return under.map((row) => `${row.wallet} ${usd(row.funded)} (needs ${usd(row.needs)})`).join(", ");
142
+ }
143
+ /**
144
+ * The budget a re-run scoped to exactly `under` would need, given how `planFunding` will split it:
145
+ * each sleeve gets its declared share, or an even slice of however many are being redeployed.
146
+ * COMPUTED, never templated — the taxonomy's rule 4 exists because a hint that can be nonsense for
147
+ * some state is a bug, and "deploy at the package minimum" is nonsense when only one sleeve is
148
+ * being replaced.
149
+ */
150
+ function budgetToCover(rerunFunds, pkg, min) {
151
+ // A declared share of 0 is kept verbatim by `sharesOf`, so that wallet is floored at MIN_WALLET
152
+ // at EVERY budget — no number satisfies it, and including one in the max would send this to
153
+ // Infinity. Such a sleeve never shapes the TARGET (the fix is the manifest, not the budget — see
154
+ // `zeroShareSentence`), but when the re-run will fund it anyway (it has no wallet) it stays in
155
+ // `rerunFunds` and so in the even-split DENOMINATOR: `planFunding` divides evenly over every
156
+ // wallet being funded, zero-share ones included, and a hint sized against a smaller split
157
+ // under-hints the agent straight back into this warn. (An ABSENT share is different — it becomes
158
+ // an even slice, which does scale.)
159
+ const scalable = rerunFunds.filter((name) => pkg.instances.find((i) => i.name === name)?.fundingShare !== 0);
160
+ if (scalable.length === 0)
161
+ return 0;
162
+ const evenShare = 1 / rerunFunds.length;
163
+ const needsOf = new Map(min.breakdown.map((row) => [row.wallet, row.perWalletMin]));
164
+ const worst = scalable.reduce((most, name) => {
165
+ const declared = pkg.instances.find((inst) => inst.name === name)?.fundingShare;
166
+ const share = declared !== null && declared !== undefined && declared > 0 ? declared : evenShare;
167
+ return Math.max(most, (needsOf.get(name) ?? 0) / share);
168
+ }, 0);
169
+ // Ceil to whole cents so `budget × share` clears the requirement BEFORE `planFunding`'s round2.
170
+ return Math.ceil(worst * 100) / 100;
171
+ }
172
+ /**
173
+ * The fix-the-manifest sentence for `funding_share: 0` sleeves — the one shortfall this escape can
174
+ * NEVER remediate. `planFunding` keeps a declared 0 verbatim, so the wallet is floored at
175
+ * MIN_WALLET at every budget: closing it and re-deploying bigger funds it straight back at the
176
+ * floor, which is the destructive no-op loop the close command must not offer. When such a sleeve
177
+ * is the ONLY short one, this sentence IS the whole escape — deliberately with nothing to run.
178
+ */
179
+ function zeroShareSentence(names) {
180
+ const one = names.length === 1;
181
+ return (`Sleeve${one ? "" : "s"} ${names.join(", ")} declare${one ? "s" : ""} \`funding_share: 0\`, so ` +
182
+ `NO budget can lift ${one ? "it" : "them"} above the ${usd(MIN_WALLET)} floor and closing/` +
183
+ `re-deploying cannot change that — the fix is a share in strategy.yaml, not more money.`);
184
+ }
185
+ /**
186
+ * The close-then-redeploy escape — the ONLY sequence that actually raises a deployed wallet's size.
187
+ *
188
+ * A bare re-run at a bigger `--budget` is a guaranteed no-op: reconcile matches the strategy by
189
+ * name, ADOPTS it, skips create, and answers with `buildAdoptedWalletDetail`'s "the requested … was
190
+ * NOT added". The agent then has to reverse-engineer the real step from a message it did not
191
+ * expect, and the two likeliest readings are "the deploy failed" and "close it to start clean".
192
+ *
193
+ * SCOPED to the underfunded sleeves. `close.py <id>` tears down the WHOLE package, so on a
194
+ * partially-adopted deploy it would close the adopted, live, funded sleeve that this warn is not
195
+ * even about — the "close-to-replace teardown" class the taxonomy's rule 5 exists to prevent.
196
+ * `--instance` narrows it, and is only omitted when every sleeve in the package really is affected.
197
+ *
198
+ * `existing` is the subset of `under` whose wallets ACTUALLY EXIST on the finished report. An empty
199
+ * one yields no escape at all: on a deploy that failed before create there is no "it" to close, and
200
+ * offering a teardown for a wallet that was never made is a false premise plus a pointless step.
201
+ */
202
+ export function buildBudgetEscape(pkg, min, state) {
203
+ // E_ROLLBACK_INCOMPLETE owns the remediation wherever it appears. It names a stranded funded
204
+ // wallet and the command to reclaim it; a second close instruction beside it — scoped differently,
205
+ // and unrunnable besides — is a report contradicting itself, and an agent reconciling the two
206
+ // picks whichever one executes. Defer, and say who to follow.
207
+ if (state.rollbackIncomplete) {
208
+ return (` Deal with the [E_ROLLBACK_INCOMPLETE] line on this report FIRST — it owns the cleanup here, ` +
209
+ `and its instruction is the one to follow. Re-size only once that wallet is settled.`);
210
+ }
211
+ // A wallet with no runtime cannot be closed by instance at all: `close.py --instance` resolves the
212
+ // sleeve THROUGH its runtime and hard-exits without one, telling the reader to "omit --instance to
213
+ // close all of <id>" — the whole-package teardown of any adopted sleeve beside it. Never emit a
214
+ // command whose precondition this report does not meet; point at read-only triage instead.
215
+ if (state.strandedNames.length > 0) {
216
+ // Deliberately names NO teardown command. Per-sleeve teardown resolves the sleeve through its
217
+ // runtime, which these wallets do not have; the package-wide form would take down every sleeve
218
+ // including any adopted live one. The only command here is read-only, so there is nothing to
219
+ // copy that could destroy anything.
220
+ return (` Re-sizing is NOT safe to automate on this report: wallet(s) ${state.strandedNames.join(", ")} ` +
221
+ `were funded but no runtime was installed on them, which is the one state a per-sleeve teardown ` +
222
+ `cannot resolve — and a package-wide one would take down every other sleeve with it, adopted ` +
223
+ `live ones included. Triage read-only first: ` +
224
+ `\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`, then decide with the user.`);
225
+ }
226
+ // A zero-share short sleeve is not remediable by this escape AT ALL — no budget moves it off the
227
+ // floor (see `zeroShareSentence`) — so it never reaches `closable` and never shapes the hint's
228
+ // target. When it is the only short sleeve, the manifest sentence is the entire escape.
229
+ const zeroShareNote = state.zeroShare.length > 0 ? ` ${zeroShareSentence(state.zeroShare)}` : "";
230
+ if (state.closable.length === 0)
231
+ return zeroShareNote;
232
+ // The id and the package dir are quoted the way `--instance` is: shell input, copied verbatim.
233
+ const wholePackage = state.closable.length === pkg.instances.length;
234
+ const closeCmd = wholePackage
235
+ ? `python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'`
236
+ : state.closable
237
+ .map((name) => `python3 senpi-strategy-ops/scripts/close.py '${pkg.id}' --instance '${name}'`)
238
+ .join(" && ");
239
+ // The scoped clause NAMES the sleeves it closes rather than saying "the underfunded sleeve(s)":
240
+ // a zero-share sleeve can be underfunded yet deliberately absent from the command, and prose that
241
+ // describes a wider set than the command executes is the self-contradiction this note exists to
242
+ // avoid.
243
+ const closeClause = wholePackage
244
+ ? `Close it first (\`${closeCmd}\`, which returns the funds)`
245
+ : `Close ONLY ${state.closable.join(", ")} first — the rest of the package stays up ` +
246
+ `(\`${closeCmd}\`, which returns the funds)`;
247
+ // The re-run funds every walletless instance, not just the ones being closed — a sleeve whose own
248
+ // create failed is in that set too, and sizing the hint against the closed set alone under-hints
249
+ // it straight back into this same warn.
250
+ const rerunFunds = [...new Set([...state.closable, ...state.walletless])];
251
+ const redeployBudget = wholePackage ? min.minBudget : budgetToCover(rerunFunds, pkg, min);
252
+ // Mirror of `underfundedNote`'s guard, on the module's other money-hint producer: below
253
+ // MIN_WALLET no rendered `--budget` is valid, and suggesting one produces nonsense the agent
254
+ // then follows ("--budget ≤ $0", the M381223 churn).
255
+ //
256
+ // No longer reachable through `runDeploy`: its only route in was a mis-authored share > 1, and
257
+ // `sharesOverAuthorization` now refuses that at the preflight, before a wallet is funded. With
258
+ // every share at or below 1 the implied figure is `perWalletMin / share ≥ perWalletMin`, and
259
+ // `min_budget` floors perWalletMin at MIN_WALLET, so it cannot fall under it. Kept as defence in
260
+ // depth — it costs one comparison, and it is the second half of a rule the other producer also
261
+ // enforces locally.
262
+ if (redeployBudget < MIN_WALLET)
263
+ return zeroShareNote;
264
+ return (` Running the authored design means starting over at the larger size, NOT re-running at a bigger ` +
265
+ `budget: deploy never adds funds to a wallet that already exists, so a re-run would just adopt ` +
266
+ `what is already there. ${closeClause}, then ` +
267
+ `\`openclaw senpi deploy -p '${pkg.dir}' --budget ${budgetArg(redeployBudget)}\`.${zeroShareNote}`);
268
+ }
269
+ /** Forward-looking tense for the preflight narration: everything is about to be funded and run. */
270
+ export function forwardTense(under) {
271
+ const names = under.map((row) => row.wallet);
272
+ return { funded: new Set(names), running: new Set(names) };
273
+ }
274
+ /**
275
+ * The SOFT tier's warn: one or more wallets this deploy funds get less than their own sizing needs.
276
+ *
277
+ * It must not read like the `[E_FUNDS_*]` refusals that share its `[E_…]` shape, because it did not
278
+ * stop anything — users size their own budgets, and a strategy that runs with fewer slots than
279
+ * authored is a legitimate choice, not a fault. Hence the explicit "nothing was blocked": without
280
+ * it, an agent that pattern-matches bracketed codes to "refused" would report a live deploy as a
281
+ * failure and start closing wallets to "fix" it. The phrasing is state-independent ("the budget
282
+ * check did not stop this deploy") because this note also rides `failed` reports, where the old
283
+ * "this deploy PROCEEDS" was simply untrue.
284
+ *
285
+ * The whole-package minimum is quoted as CONTEXT, in its own parenthesis — never as the claim.
286
+ */
287
+ function buildBudgetBelowMinNote(pkg, min, under, escape, tense) {
288
+ // "Will run" is a claim about something INSTALLED, judged per row: a mixed report used to say
289
+ // "those sleeve(s) WILL RUN DEGRADED" about wallets it named as stranded two clauses later. The
290
+ // sizing observation itself stays in every branch — it was true about the plan.
291
+ const running = under.map((row) => row.wallet).filter((name) => tense.running.has(name));
292
+ const degraded = running.length === under.length
293
+ ? `those sleeve(s) will run DEGRADED, with`
294
+ : running.length === 0
295
+ ? `at that size those sleeve(s) would run DEGRADED, with`
296
+ : `of these, ${running.join(", ")} will run DEGRADED — the rest never got a runtime ` +
297
+ `installed on this run — with`;
298
+ // "This deploy funds" is a claim about the WALLET, judged per row too: on a deploy that never
299
+ // created one, present tense states an allocation that did not happen — while "would have funded"
300
+ // about a wallet that IS live and funded (stranded, rollback-incomplete) contradicts the very
301
+ // line that names it. Each row's own fact decides.
302
+ const fundedCount = under.filter((row) => tense.funded.has(row.wallet)).length;
303
+ const lead = fundedCount === under.length
304
+ ? `This deploy funds ${under.length} wallet(s) below what their own sizing needs`
305
+ : fundedCount === 0
306
+ ? `This deploy would have funded ${under.length} wallet(s) below what their own sizing needs`
307
+ : `This deploy funds ${fundedCount} of ${under.length} wallet(s) below what their own ` +
308
+ `sizing needs (the rest never got a wallet)`;
309
+ return (`[W_BUDGET_BELOW_STRATEGY_MIN] ${lead}: ${renderUnderfunded(under)}. Nothing was blocked — the ` +
310
+ `budget check did not stop this deploy; ${degraded} fewer slots than designed and each ` +
311
+ `position a larger share of its wallet. (For reference, deploying the whole ${pkg.id} package ` +
312
+ `fresh needs ${usd(min.minBudget)} across ${min.walletCount} wallet(s).)${escape}`);
313
+ }
314
+ /**
315
+ * The SOFT tier's other half: a sleeve whose slot size could not be read out of its runtime.yaml
316
+ * (the vol-parity sleeves expose risk weights, not margin percentages). Its per-wallet minimum fell
317
+ * back to the bare floor, so the figure is a LOWER bound — saying it plainly is the whole point,
318
+ * because the alternative is advertising a $10 minimum for a design that no-trades below $250.
319
+ *
320
+ * A concrete shortfall is APPENDED rather than replacing this note. Both facts matter and the
321
+ * understatement is the more important caveat, so it leads — but "these wallets are already short"
322
+ * is the one hard, checkable thing available, and suppressing it (the shape this had when the two
323
+ * branches were exclusive) left the agent with the softer half of the truth while `belowMin`
324
+ * reported the opposite of it. Once it asserts that shortfall it must also name the way out, or an
325
+ * agent reading a concrete number with no escape will improvise one — and the improvisations this
326
+ * repo has field cases for are a bare re-run and a close.
327
+ */
328
+ function buildBudgetUnresolvedNote(pkg, min, under, escape, tense) {
329
+ // Same per-row funding tense as the below-min note (see there for why).
330
+ const fundedCount = under.filter((row) => tense.funded.has(row.wallet)).length;
331
+ const funding = fundedCount === under.length
332
+ ? `is ALREADY funding ${under.length} wallet(s)`
333
+ : fundedCount === 0
334
+ ? `would have funded ${under.length} wallet(s)`
335
+ : `is ALREADY funding ${fundedCount} of ${under.length} wallet(s) (the rest never got a wallet)`;
336
+ const shortfall = under.length > 0
337
+ ? ` This deploy ${funding} below their own sizing: ${renderUnderfunded(under)}.`
338
+ : "";
339
+ return (`[W_BUDGET_UNRESOLVED] could not compute a reliable minimum for ${pkg.id} — sleeve(s) ` +
340
+ `${min.unresolvedWallets.join(", ")} expose no resolvable marginPct, so the ${usd(min.minBudget)} ` +
341
+ `figure may be UNDERSTATED. Size conservatively.${shortfall} Nothing was blocked — the budget ` +
342
+ `check did not stop this deploy.${escape}`);
343
+ }
344
+ /** Which code leads the soft-tier note, or null when there is nothing to say. */
345
+ function softLead(min, under) {
346
+ if (min.unresolvedWallets.length > 0)
347
+ return "unresolved";
348
+ return under.length > 0 ? "below" : null;
349
+ }
350
+ /**
351
+ * The soft-tier note, assembled from the facts plus whatever escape the caller's state supports.
352
+ * ONE producer for both codes' strings — the preflight narration and the final report differ only
353
+ * in `escape`, so they can never drift into two different accounts of the same finding.
354
+ */
355
+ export function buildSoftBudgetNote(pkg, min, under, escape, tense) {
356
+ switch (softLead(min, under)) {
357
+ case "unresolved":
358
+ return buildBudgetUnresolvedNote(pkg, min, under, escape, tense);
359
+ case "below":
360
+ return buildBudgetBelowMinNote(pkg, min, under, escape, tense);
361
+ default:
362
+ return null;
363
+ }
364
+ }
365
+ /**
366
+ * The fraction landed, FLOORED to a tenth of a percent.
367
+ *
368
+ * Floored rather than rounded on purpose: this warn fires strictly below 90%, and a rounded 89.97%
369
+ * would print "(90%)" beside a sentence explaining that the wallet came up short of 90% — the kind
370
+ * of self-contradiction that makes a reader distrust the whole line. Trailing `.0` is dropped so
371
+ * the ordinary case reads `12%`, not `12.0%`.
372
+ *
373
+ * Computed in integer CENTS. Flooring a raw double reintroduced the misprint the flooring exists to
374
+ * prevent, away from the boundary: `(6.80 / 10.00) * 1000` is 679.999… in binary floating point, so
375
+ * an exact 68% printed `67.9%`. Cents are what the figures actually are.
376
+ */
377
+ function fundedPercent(row) {
378
+ const fundedCents = Math.round(row.funded * 100);
379
+ const requestedCents = Math.round(row.requested * 100);
380
+ return `${Math.floor((fundedCents * 1000) / requestedCents) / 10}%`;
381
+ }
382
+ /**
383
+ * `main (0x…), created by this deploy, funded $60.00 of requested $500.00 (12%, short $440.00)` —
384
+ * both numbers, so the claim is checkable, and the provenance and the shortfall folded into the same
385
+ * clause so each wallet is named exactly once.
386
+ *
387
+ * The ask is attributed to whoever made it. On the adoption path the amount comes from a PRIOR run's
388
+ * journal, so "of requested $500.00" under a lead saying "what this deploy requested" described a
389
+ * request this run never made — against a wallet it never funded. The adopted clause names that run
390
+ * instead, and a reader who wants the current number is sent to `status.py` by the remediation.
391
+ */
392
+ function renderPartialFunded(rows) {
393
+ return rows
394
+ .map((row) => {
395
+ const asked = row.created
396
+ ? `${row.instance} (${row.wallet}), created by this deploy, funded ${usd(row.funded)} of ` +
397
+ `requested ${usd(row.requested)}`
398
+ : `${row.instance} (${row.wallet}), adopted by this deploy from an earlier run, funded ` +
399
+ `${usd(row.funded)} of the ${usd(row.requested)} that run requested`;
400
+ return `${asked} (${fundedPercent(row)}, short ${usd(row.requested - row.funded)})`;
401
+ })
402
+ .join(", ");
403
+ }
404
+ /**
405
+ * The OUTCOME tier's warn: the backend funded a wallet for materially less than the create call
406
+ * asked for (a partial bridge, a rejected top-up leg — the deploy sees only the result). The
407
+ * successor to the skills-side verify gate's `_budget_verdict`, which reported the whole strategy
408
+ * "not live" over the same finding.
409
+ *
410
+ * It is a `W_`, and the difference from that predecessor is the point: the strategy IS live, its
411
+ * wallet holds real money and its runtime is ticking. Reporting `not-live` (or letting this change
412
+ * `overall`) would send an agent to close a funded, running strategy over a shortfall a top-up
413
+ * fixes. So it warns, the exit code stays whatever the deploy earned, and the remediation leads
414
+ * with the NON-destructive route.
415
+ *
416
+ * It deliberately emits NO teardown command. `close.py`'s correct scope depends on state this note
417
+ * does not own — adopted siblings, wallets stranded with no runtime, an `[E_ROLLBACK_INCOMPLETE]`
418
+ * line already claiming the cleanup — and the report already has exactly one producer of close
419
+ * commands ({@link buildBudgetEscape}). A second, differently scoped teardown beside it is the
420
+ * self-contradiction the taxonomy's rule 5 exists to prevent, and an agent reconciling two of them
421
+ * runs whichever executes. The destructive option is named as an option and routed through
422
+ * read-only triage, which is the only command here and cannot destroy anything.
423
+ *
424
+ * Two properties of the note are load-bearing and easy to lose:
425
+ * - `reassure` — the "nothing is broken, the strategy is LIVE" sentence is a CLAIM about the rest
426
+ * of the report, so it is only made when the report PROVES it: `overall === "live"` and nothing
427
+ * less (see the gate at the call site). Asserted unconditionally it rode `failed` and `pending`
428
+ * reports saying the opposite two lines above; gated only loosely it rode
429
+ * `installed-unobserved` ones, where no tick was ever seen.
430
+ * - the figures are DATED and the top-up is routed through a fresh read. This note persists on the
431
+ * job snapshot and re-renders on every later `deploy status`, and unlike every other budget note
432
+ * it asserts a MUTABLE backend quantity whose fix is exactly the action that invalidates it — so
433
+ * an undated "short $440.00" invites a second $440 top-up after the first one landed.
434
+ * - PROVENANCE, exactly as its unreadable sibling carries it. This note is reachable on the
435
+ * ADOPTION path too — the ask is looked up from a prior run's journal for the strategy this run
436
+ * adopted — where "SHORT of what this deploy requested … funded $60.00 of requested $500.00"
437
+ * named a request this run never made about a wallet it never funded ($500 was the earlier run's
438
+ * ask; this one asked for nothing and created nothing). The lead attributes the ask to no one and
439
+ * each wallet states its own origin, so a reader can correct the figure from the note itself.
440
+ */
441
+ export function buildPartialFundNote(pkg, rows, reassure) {
442
+ // The size claim is about the WALLETS THIS NOTE NAMES, never about the strategy. `rows` is the
443
+ // filtered SHORT set, so "the strategy is LIVE, but it is trading a $20.00 book" read a
444
+ // whole-package figure out of one sleeve of it: on a deploy whose other sleeve landed its full
445
+ // $300, the strategy was trading $320 across two wallets. Same rule the unreadable note's
446
+ // standing sentence follows — a filtered set speaks only for the wallets in it.
447
+ const book = rows.length === 1
448
+ ? `that wallet is trading a ${usd(rows[0].funded)} book`
449
+ : `those wallets are trading only what landed on them`;
450
+ const standing = reassure
451
+ ? `Nothing was blocked and nothing is broken — the strategy is LIVE, but ${book}, `
452
+ : `Read the step lines above first — they, not this note, say what state this ` +
453
+ `deploy left behind. Either way ${book}, `;
454
+ return (`[W_BUDGET_PARTIAL_FUND] The backend funded ${rows.length} wallet(s) SHORT of what was ` +
455
+ `requested for them, as the backend reported them at deploy time: ${renderPartialFunded(rows)}. ` +
456
+ `${standing}so every position opened there is sized off the money that actually landed, not ` +
457
+ `off the amount requested. Report the funded figure, never the requested one. Fix it the ` +
458
+ `non-destructive way FIRST — and re-read the CURRENT funding before moving any money, because ` +
459
+ `those figures are what the backend reported AT DEPLOY TIME and a top-up (or a slow bridge leg ` +
460
+ `settling late) makes them stale: run ` +
461
+ `\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`, then add whatever difference it ` +
462
+ `still shows through the \`senpi-deposit-withdraw-transfer\` skill. Deploy itself never adds ` +
463
+ `funds to a wallet that already exists, so re-running at the same budget would only adopt what ` +
464
+ `is there. Closing and redeploying at the full size is the alternative and it is DESTRUCTIVE: ` +
465
+ `agree the scope with the user against that same read, because a teardown here also takes down ` +
466
+ `whatever else this package has live.`);
467
+ }
468
+ /**
469
+ * The unreadable-read sibling of {@link buildPartialFundNote}: the ask is known, the landed amount
470
+ * is not, so this note makes NO percentage claim, NO dollar shortfall, and asks for no money move.
471
+ *
472
+ * The failure it replaces: the client coerced a missing/unparseable `totalFunded` to `0`, which
473
+ * turned one bad read into `funded $0.00 of requested $500.00 (0%)` plus an instruction to add
474
+ * $500 — against a wallet that may already hold the full amount, so following it doubled the
475
+ * deployment. An unread figure is not a small figure; the only honest next step is for the USER to
476
+ * establish what is actually on the wallet.
477
+ *
478
+ * `reassure` is the same earned-claim gate its sibling carries, and for the same reason: "the
479
+ * create succeeded and the strategy is running" is a claim about the REST of the report, and this
480
+ * note is reachable on `failed` and `pending` reports through the adoption path — asserted
481
+ * unconditionally it rode a verdict saying the opposite two lines above. Without it the note keeps
482
+ * every fact it owns (the amount is unreadable, verify before moving money) and makes no claim
483
+ * about what the deploy left running.
484
+ *
485
+ * PROVENANCE is the other claim it may not invent, and it is claimed PER WALLET only. The note is
486
+ * reachable on the ADOPTION path — a journaled ask is looked up for exactly the strategy a prior
487
+ * run created — so "wallet(s) this deploy created" described a create that never happened on a
488
+ * crash-resume that adopted, created nothing and funded nothing, handing the reader a second wallet
489
+ * that does not exist. Each wallet therefore states its own origin from
490
+ * {@link UnreadableFundedWallet.created}.
491
+ *
492
+ * No sentence here generalises that over the deploy. `rows` is a FILTERED set — the wallets whose
493
+ * funded figure came back unreadable — so nothing about the OTHER wallets can be read out of it,
494
+ * and a `rows.every(...)` looks exactly like a deploy-level fact while being one about the subset:
495
+ * a deploy that created `main` (readable $300) and adopted `scalp` (unreadable) has `rows =
496
+ * [scalp]`, and "this deploy created no wallet" is then flatly false about the $300 it just moved.
497
+ * The only deploy-level claim allowed is the one `reassure` proves — the report says `live`, so the
498
+ * strategy is running — and it names no create and no adoption.
499
+ */
500
+ export function buildFundedUnreadableNote(pkg, rows, reassure) {
501
+ const wallets = rows
502
+ .map((row) => row.created
503
+ ? `${row.instance} (${row.wallet}), created by this deploy, requested ${usd(row.requested)}`
504
+ : `${row.instance} (${row.wallet}), adopted by this deploy from an earlier run that ` +
505
+ `requested ${usd(row.requested)}`)
506
+ .join(", ");
507
+ const standing = reassure
508
+ ? `Nothing was blocked and the strategy is running; what is UNKNOWN is `
509
+ : `Read the step lines above first — they, not this note, say what state this deploy left ` +
510
+ `behind. What is UNKNOWN here is `;
511
+ return (`[W_BUDGET_FUNDED_UNREADABLE] The backend did not report a readable funded amount for ` +
512
+ `${rows.length} wallet(s): ${wallets}. ${standing}` +
513
+ `how much money landed on it — do not state it as ` +
514
+ `zero, and do not state it as the requested amount. Move no money on this: ask the USER to ` +
515
+ `verify the actually-funded amount first ` +
516
+ `(\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`, or inspecting the wallet), and ` +
517
+ `only then decide whether anything needs topping up or resizing.`);
518
+ }
519
+ export function buildDecisionModelRefusal(instance, pkg, budget) {
520
+ return (`[INVALID_REQUEST] Instance "${instance}" runs actions with decision_mode: llm, but no decision ` +
521
+ `model was given. Nothing was created. Re-run naming the model: ` +
522
+ `${resumeCommand(pkg.dir, budget)} --decision-model <model>`);
523
+ }
524
+ export function buildDecisionModelEnvRefusal(instance, pkg) {
525
+ return (`[INVALID_REQUEST] Instance "${instance}" runs actions with decision_mode: llm, but ` +
526
+ `${pkg.dir}/strategy.yaml declares no defaults.decision_model_env — there is no token to ` +
527
+ `substitute the model into. Nothing was created. Add ` +
528
+ `defaults.decision_model_env to strategy.yaml, bind it in the runtime.yaml, then re-run.`);
529
+ }
530
+ /**
531
+ * An install that came up with NO live entry scanners (`payload.unwired`). The runtime came UP and
532
+ * is BLIND, and every one of the three phases is deterministic on this box — so this is an install
533
+ * failure, not a tick that has not landed yet.
534
+ *
535
+ * Says what is true (it will never tick), what was NOT done (no scanner ever mounted), and — the
536
+ * part that has to track reality — what is LEFT on the box. The two teardown outcomes leave
537
+ * different states, so they never share a sentence: naming `senpi runtime delete` after a
538
+ * successful delete points at a runtime that no longer exists, and omitting it after a failed one
539
+ * hides a runtime still running against a wallet this job is about to close.
540
+ *
541
+ * The NEXT STEP is per-phase for the same reason. One ladder ("delete, fix, re-run") cannot serve
542
+ * all three: only `install_wire` is fixable in the package, and on `no_intake` re-running is a loop
543
+ * that cannot terminate — create → fund → install → unwired → close, moving real money out and
544
+ * back on every iteration — because the cause is box-level and the intake context is built once at
545
+ * plugin register. No emitted command may require a precondition the state lacks.
546
+ */
547
+ export function buildUnwiredInstallDetail(params) {
548
+ const { phase, runtimeId, wallet, createdByThisJob, removal } = params;
549
+ const { cause, next } = describeUnwiredPhase(phase, createdByThisJob);
550
+ // Deleting a runtime tears down DSL exit management for its wallet ADDRESS — it cancels the
551
+ // exchange stop-loss and drops the backend ratchet stop for every ACTIVE DSL there. On a wallet
552
+ // this job created, the rollback closes it moments later, so there is nothing left to be
553
+ // unmanaged; on an ADOPTED wallet nothing closes, so the consequence has to be said out loud.
554
+ const dslNote = createdByThisJob || !removal.ok
555
+ ? ""
556
+ : ` Deleting it also tore down DSL exit management for ${wallet}, which this deploy did not ` +
557
+ `create: any open position there has lost its ratchet stop and its exchange stop-loss, and ` +
558
+ `nothing is watching it — check with MCP \`strategy_get_clearinghouse_state\` on ${wallet} ` +
559
+ `before you leave this.`;
560
+ const left = removal.ok
561
+ ? `The runtime was deleted, so nothing is left running against this wallet; the cause is in ` +
562
+ `the gateway log (event \`runtime.scanners_unwired\`, phase ${phase}).${dslNote}`
563
+ : `The runtime could NOT be deleted (${removal.error}) and is STILL RUNNING against this ` +
564
+ `wallet — read it with \`openclaw senpi status -r ${runtimeId}\`, then remove it yourself: ` +
565
+ `\`openclaw senpi runtime delete ${runtimeId}\`.`;
566
+ return (`install produced a BLIND runtime "${runtimeId}": ${cause} (phase ${phase}), so it has no entry ` +
567
+ `scanners and would never tick. Counted as an install failure. ${left} ${next}`);
568
+ }
569
+ /**
570
+ * The install step's detail when this deploy stopped waiting on an install it could not cancel.
571
+ *
572
+ * Every clause here is load-bearing. The install is IN-PROCESS work that takes no signal, so this
573
+ * deploy giving up says nothing about what the install did — and a detail that guessed either way
574
+ * would send a reader to close a wallet a runtime may have just come up on. So the claim is the
575
+ * only one both reads support: the outcome is unknown. The read that resolves it is free and
576
+ * non-destructive, so it leads; the money-moving step is conditional on its answer, and carries the
577
+ * same live-sleeve caveat the rollback path uses, because the same package may hold other wallets.
578
+ *
579
+ * The detail forks on TWO facts, because one was not enough to make either instruction true.
580
+ *
581
+ * `createdByThisJob` (the same flag {@link buildUnwiredInstallDetail} branches on) decides whether a
582
+ * close is even on the table. A wallet this job created was never closed BECAUSE the install might
583
+ * still bind it — the causal claim, and the reclaim instruction, both hold, and the money is this
584
+ * job's own to reclaim. A wallet this job ADOPTED predates the deploy: D-6 never closes an adopted
585
+ * wallet regardless of how the install lands, so "would have closed it but for the race" is false on
586
+ * that wallet, and `funded` is the caller's pre-existing balance — not a close target this deploy
587
+ * has any standing to name.
588
+ *
589
+ * `stillInFlight` is the wallet's install FENCE, read as this detail is written, and it decides
590
+ * whether ANY instruction may be given yet. `installRuntimeCore` writes its registry row and takes
591
+ * its MCP wallet-ownership check BEFORE `run()` — the modal wedge — so at this instant the install
592
+ * can be sitting anywhere in that sequence with the fence still held. Two things follow, and both
593
+ * used to be wrong here:
594
+ * - a registry ROW is not proof the install landed. The row is written first; `senpi.listRuntimes`
595
+ * renders exactly that state `status: "stopped"`. The landed claim is therefore keyed on the
596
+ * runtime showing RUNNING, never on a row existing.
597
+ * - while the fence is held, no close may be named at all. An install wedged at the ownership
598
+ * check has written NO row, so "if NO row names that wallet, reclaim the funds" pointed straight
599
+ * at a `strategy_close` that races the install into the one state this whole carve-out exists to
600
+ * prevent — a live runtime trading a closed strategy.
601
+ * An UNREADABLE fence (`undefined`) takes the in-flight arm: it cannot be proven clear, and the
602
+ * only unsafe mistake here is naming a close over an install that is still running.
603
+ */
604
+ export function buildInstallIndeterminateDetail(args) {
605
+ const { wallet, funded, runtimeId, deadlineMs, createdByThisJob, stillInFlight, resume, caveat } = args;
606
+ // The landed claim, keyed on RUNNING. A "stopped" row is called out by name because it is the
607
+ // likeliest thing this reader actually sees: the row is written before `run()`, so the wedge this
608
+ // detail reports for produces exactly that row.
609
+ const triage = `Read what exists before changing anything: \`openclaw senpi runtime list\` — the install ` +
610
+ `LANDED only if that list shows "${runtimeId}" on ${wallet} RUNNING. A row on its own does not ` +
611
+ `prove it: the registry row is written BEFORE the runtime starts, so a row rendered "stopped" ` +
612
+ `is an install that registered and never came up. If it is running, re-run \`${resume}\` to ` +
613
+ `observe a tick.`;
614
+ // Whose wallet this is — the one fact both settled arms and the in-flight arm state, because a
615
+ // reader deciding anything about money has to know whether this deploy created it.
616
+ const inFlightProvenance = createdByThisJob
617
+ ? `This wallet — ${fundedClause(funded)} — was created by this deploy and was NOT closed`
618
+ : `This wallet predates this deploy — ${fundedClause(funded)} — and this deploy never closes an ` +
619
+ `adopted wallet`;
620
+ const outcome = stillInFlight !== false
621
+ ? `${inFlightProvenance}. ` +
622
+ (stillInFlight === true
623
+ ? `That install is STILL RUNNING inside this gateway as this report is written`
624
+ : `Whether that install is still running could not be read here, so this report assumes ` +
625
+ `it is`) +
626
+ ` — the wallet is fenced against a second install until it settles, so its outcome is not ` +
627
+ `decided yet and nothing you can read right now is final. Change NOTHING on ${wallet} and ` +
628
+ `close NOTHING: a close now would land on a strategy that install is still binding, ` +
629
+ `leaving a live runtime trading a closed strategy. Re-read shortly — \`openclaw senpi ` +
630
+ `deploy status\`, then \`openclaw senpi runtime list\` — and decide from the settled ` +
631
+ // Scoped to THIS wallet on purpose: a re-run that reaches it is fenced, but "a re-run
632
+ // changes nothing" is a claim about the whole deploy that this detail cannot make — a
633
+ // sleeve whose strategy reconcile fails to match by name is created and funded again.
634
+ `state. A re-run that reaches this wallet is refused rather than installing over it, so it ` +
635
+ `cannot settle this for you.`
636
+ : createdByThisJob
637
+ ? `This wallet — ${fundedClause(funded)} — was NOT closed, because closing a wallet an ` +
638
+ `install is still binding would leave a runtime trading a closed strategy. That install ` +
639
+ `is no longer running in this gateway — the wallet's install fence has cleared — so what ` +
640
+ `exists now is what it left. ${triage} If that list shows "${runtimeId}" on ${wallet} ` +
641
+ `but NOT running, do not close the wallet: the next gateway restart will try to start ` +
642
+ `that runtime, so read it first (\`openclaw senpi status -r ${runtimeId}\`) and decide ` +
643
+ `with the user. If NO runtime names that wallet at all, nothing is watching the funds: ` +
644
+ `reclaim them with MCP \`strategy_close\` on ${wallet}${caveat}`
645
+ : `This wallet predates this deploy — ${fundedClause(funded)} — and this deploy never ` +
646
+ `closes an adopted wallet, whichever way the install landed. That install is no longer ` +
647
+ `running in this gateway. ${triage} Its funds are unaffected by this deploy either way; ` +
648
+ `use the read above to decide what, if anything, needs doing yourself.`;
649
+ return (`[${INSTALL_INDETERMINATE_CODE}] the install on wallet ${wallet} did not finish within ` +
650
+ `${Math.round(deadlineMs / 1000)}s, so this deploy stopped waiting for it. The install runs ` +
651
+ `inside the gateway and was NOT cancelled: it may still have completed, or it may not have. ` +
652
+ `${outcome}`);
653
+ }
654
+ /**
655
+ * The install step's detail when this deploy found an install already running on the wallet.
656
+ *
657
+ * The refusal `installRuntimeCore` renders for the same condition is the enforcement copy; this one
658
+ * exists because deploy reaches the condition one step earlier — before the dead-row branch that
659
+ * would delete the in-flight install's registry row — and so can say what deploy did (nothing) as
660
+ * well as what it found. Both name the same reads and neither names a delete, and this one now
661
+ * carries the same install-bound and wedge-vs-restart facts the enforcement copy does — deploy is
662
+ * the primary path, so it is the one most readers see, and a weaker message here would invert which
663
+ * of the two is the source of truth. The one fact it deliberately does NOT reach parity on is elapsed
664
+ * time: `installPendingOnWallet` reports a boolean, not when the install started, and the job's own
665
+ * timing is already what `openclaw senpi deploy status` (named below) shows.
666
+ */
667
+ export function buildInstallInFlightDetail(args) {
668
+ const boundSec = Math.round(INSTALL_DEADLINE_MS / 1000);
669
+ return (`[${WALLET_INSTALL_IN_FLIGHT_CODE}] an install is already running on wallet ${args.wallet} ` +
670
+ `(runtime "${args.runtimeId}") inside this gateway, so nothing was installed or deleted here — ` +
671
+ `a second runtime on one wallet would put two exit engines on one margin account. This normally ` +
672
+ `clears on its own once that install finishes. A deploy's own install bound is ${boundSec}s — ` +
673
+ `well past that, this install is more likely wedged than merely slow, and if so the fence on ` +
674
+ `this wallet will not clear until this gateway process restarts. Read what that install produced ` +
675
+ `before acting: openclaw senpi deploy status, then openclaw senpi runtime list.`);
676
+ }
677
+ /**
678
+ * The cause and the next step for one unwired phase — computed from the phase, never templated.
679
+ *
680
+ * `no_intake`'s stake depends on what this job actually did: only a run that CREATES a wallet moves
681
+ * money on every doomed iteration. Asserting that on an adopted wallet (where a re-run installs and
682
+ * closes nothing) would overstate a warning that is already strong enough on its own facts.
683
+ */
684
+ function describeUnwiredPhase(phase, createdByThisJob) {
685
+ if (phase === "no_intake") {
686
+ const loop = createdByThisJob
687
+ ? `every re-run repeats create → fund → install → close and moves real money out and back each time`
688
+ : `every re-run repeats install → delete on this adopted wallet — no funds move, but nothing ` +
689
+ `is ever left watching it`;
690
+ return {
691
+ cause: `supervised scanner intake is disabled for this whole box, so no external scanner could ` +
692
+ `be mounted — nothing about this package or this runtime caused it`,
693
+ next: `Do NOT re-run the deploy yet: the cause is box-level and is decided once when the plugin ` +
694
+ `starts, so ${loop}. Repair or clear the plugin's \`acceptance-record.json\` state file ` +
695
+ `and RESTART the gateway first (the boot log names the file with its full path); re-run ` +
696
+ `only once a fresh install wires its scanners.`,
697
+ };
698
+ }
699
+ if (phase === "install_launch") {
700
+ return {
701
+ cause: `the supervised scanner launcher failed to start`,
702
+ next: `Read the launcher error in the gateway log before re-running — a re-run repeats it ` +
703
+ `verbatim while the cause is still there.`,
704
+ };
705
+ }
706
+ return {
707
+ cause: `the supervised scanner wiring failed (a packaging or entrypoint error in this package)`,
708
+ next: `Fix the scanner entrypoint this package declares (the \`path\`/module of each external ` +
709
+ `scanner in the instance runtime.yaml), then re-run the deploy.`,
710
+ };
711
+ }
712
+ /**
713
+ * A strategy the ACTIVE poll watched go DEAD. Terminal, so the poll stops on it — and the claim it
714
+ * replaces is the point: `buildCreatePendingDetail` would have said "waits for it to reach ACTIVE,
715
+ * never creating a second wallet", and on this input neither half holds. It never reaches ACTIVE,
716
+ * and reconcile filters dead statuses on BOTH match routes, so a re-run creates and funds a fresh
717
+ * wallet beside it. The status only ever survived in the step evidence; here it leads the claim.
718
+ */
719
+ export function buildCreateDeadDetail(pkg, budget, live, createdByThisJob) {
720
+ const origin = polledWalletOrigin(createdByThisJob);
721
+ const target = polledWalletTarget(live);
722
+ // A teardown instruction is earned HERE and only here: a dead status proves the strategy is not
723
+ // coming back, so money left on it really is stranded.
724
+ const funds = live.totalFunded === undefined
725
+ ? `The backend reported no readable funded amount for it, so whether anything is stranded is ` +
726
+ `UNKNOWN — read it with MCP \`strategy_list\` on ${target} before deciding.`
727
+ : live.totalFunded > 0
728
+ ? `It still reports ${usd(live.totalFunded)} funded: reclaim that FIRST with MCP ` +
729
+ `\`strategy_close\` on ${target}, or the money stays in a dead strategy.`
730
+ : `It reports ${usd(live.totalFunded)} funded, so there is likely nothing to reclaim — ` +
731
+ `confirm with MCP \`strategy_list\` on ${target} before re-running.`;
732
+ return (`strategy ${live.id} went ${live.status} while waiting for ACTIVE — a dead status is terminal, ` +
733
+ `so the wait stopped here rather than running out the clock. Nothing was installed. ${origin}. ` +
734
+ `${funds} A re-run does NOT adopt a dead strategy (reconcile filters dead statuses on both ` +
735
+ `match routes), so \`${resumeCommand(pkg.dir, budget)}\` WILL create and fund a NEW wallet beside ` +
736
+ `this one.`);
737
+ }
738
+ /**
739
+ * A strategy the ACTIVE poll watched settle on a status that is neither ACTIVE nor on the way
740
+ * there — PAUSED, mid-teardown, or one this build does not recognise.
741
+ *
742
+ * Only {@link INITIALIZING_STATUSES} resolve on their own, so only they are worth waiting on;
743
+ * everything else burned the full `--max-wait` and was then reported with the bounded-wait detail's
744
+ * "re-run; reconciliation waits for it to reach ACTIVE" promise, which none of them will honour.
745
+ *
746
+ * The REMEDIATION branches, and must: the create step gets no D-6 rollback (only an install failure
747
+ * rolls a wallet back), so a wallet this job created and funded seconds ago is still live when this
748
+ * is read. One shared "reclaim it FIRST with `strategy_close`" told an agent to tear down a wallet
749
+ * on a status this build cannot interpret — the exact opposite of what
750
+ * {@link buildNotDeployableRefusal} says for the same input at the reconcile seam ("will not guess
751
+ * … NEVER close or recreate to 'start clean'"). The two seams now agree arm for arm: a teardown is
752
+ * offered only where the status PROVES the strategy is not coming back.
753
+ */
754
+ export function buildCreateNotDeployableDetail(pkg, budget, live, createdByThisJob,
755
+ /**
756
+ * The scoped start-over clause — see {@link buildPackageCloseOffer}. Used by the PAUSED arm
757
+ * only. Completes the sentence "If you meant to start over instead, …" and carries its own
758
+ * terminal period; the arm appends nothing to it.
759
+ */
760
+ closeOffer) {
761
+ const status = live.status.toUpperCase();
762
+ const origin = polledWalletOrigin(createdByThisJob);
763
+ const rerun = `re-run \`${resumeCommand(pkg.dir, budget)}\``;
764
+ // `pkg.id` is single-quoted everywhere it reaches a shell, exactly as buildStrandedCloseCaveat
765
+ // quotes it: these strings get copied verbatim into a terminal.
766
+ const statusPy = `\`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\``;
767
+ const head = `strategy ${live.id} is ${live.status} while this deploy waited for ACTIVE, so the wait stopped ` +
768
+ `here — this is not a funding delay. Nothing was installed. ${origin}. `;
769
+ if (status === "PAUSED") {
770
+ return (`${head}A paused strategy will not become ACTIVE on its own. Resume it, then ${rerun}. If you ` +
771
+ `meant to start over instead, ${closeOffer} Never leave it paused and fund a second wallet ` +
772
+ `beside it.`);
773
+ }
774
+ if (CLOSING_STATUSES.includes(status)) {
775
+ return (`${head}It is being torn down, and ${itsFunded(live.totalFunded)} returns to the owner wallet as ` +
776
+ `that finishes — nothing to reclaim by hand. Wait for it to reach CLOSED (re-check with ` +
777
+ `${statusPy}), then ${rerun} — the re-run creates a fresh wallet once this one is gone.`);
778
+ }
779
+ // Unrecognized. "Waiting cannot help" is a claim this build has no basis for: the status is in
780
+ // none of the sets it knows, so it may still be on its way to ACTIVE. Read-only triage only —
781
+ // and no teardown command, because nothing here proves the money is stranded.
782
+ return (`${head}This build does not recognize the status ${live.status} — it is in none of the ` +
783
+ `initializing, closing or dead sets this version knows, so deploy will not guess what it means ` +
784
+ `or whether waiting would have helped. Read-only triage FIRST: ${statusPy}, ` +
785
+ `\`openclaw senpi deploy status\`, or MCP \`strategy_get\` with strategy_id "${live.id}" (that tool takes ` +
786
+ `the id, never a wallet address). Then ${rerun} once it is ACTIVE. Do NOT close or recreate this ` +
787
+ // "seconds from ACTIVE" stood here — an unprovable duration on a rendered surface, the same
788
+ // class deleted from the bounded-wait outcome. Nothing here read how far along this strategy
789
+ // is; what this payload DOES establish is that the status is unclassifiable, which is an
790
+ // argument for not closing on its own.
791
+ `strategy to "start clean": ${itsFunded(live.totalFunded)} is not proven stranded, and a status ` +
792
+ `this build cannot place is not evidence the strategy is finished — closing it would throw away ` +
793
+ `a wallet nothing here showed to be done.`);
794
+ }
795
+ /** Who funded the polled wallet — shared by both break-outs so they can never disagree. */
796
+ function polledWalletOrigin(createdByThisJob) {
797
+ return createdByThisJob
798
+ ? `This deploy created it`
799
+ : `This deploy adopted it and funded nothing — it predates this run`;
800
+ }
801
+ /** How to name the polled wallet in a command, address-first and never `undefined`. */
802
+ function polledWalletTarget(live) {
803
+ return live.strategyWalletAddress
804
+ ? `wallet ${live.strategyWalletAddress}`
805
+ : `strategy id ${live.id} (its wallet address is unreadable)`;
806
+ }
807
+ /**
808
+ * What the last poll read said about the record, and nothing else.
809
+ *
810
+ * Both facts are quoted from the `strategy_list` row the poll already holds — no extra call, no
811
+ * interpretation. An absent row and an unreadable field are named as such rather than filled in:
812
+ * this whole surface exists because "wallet still funding" was asserted over states nobody read.
813
+ *
814
+ * The status carries one qualifier, and it is a classification this repo owns rather than a
815
+ * measurement: membership of `INITIALIZING_STATUSES`, the list whose entire definition is "statuses
816
+ * that resolve on their own, so a bounded poll is the right response" (`senpi/strategy-status.ts`).
817
+ * It is provable by a lookup against a list in this repo, it needs no hedge, and it is the reason
818
+ * waiting — and re-running to keep waiting — is the correct next step on that payload.
819
+ *
820
+ * Nothing here says how long initialization takes. The backend owns that; a figure measured once
821
+ * somewhere else is not something this deploy read, cannot be checked from here, and cannot be
822
+ * acted on — so the only durations on this surface are the wait this job bounded and the record's
823
+ * age off `createdAt`.
824
+ */
825
+ function polledRecordRead(live, nowMs) {
826
+ if (!live) {
827
+ return "the last `strategy_list` read returned no row for it, so its status and age are unread here";
828
+ }
829
+ // The empty-status arm is unreachable through `runDeploy`: an empty status is neither ACTIVE nor
830
+ // initializing, so `pollUntilActive`'s non-initializing guard reports the step `failed` through
831
+ // `buildCreateNotDeployableDetail` before the deadline branch can run, and reconcile refuses the
832
+ // same value at the match seam (`buildNotDeployableRefusal`). Kept anyway: `status` is a string
833
+ // this process read off the backend, this builder is one guard-reorder away from seeing an empty
834
+ // one, and the alternative rendering is the blank in "it reads status and was created 630s ago".
835
+ const status = live.status
836
+ ? `status ${live.status.toUpperCase()}${isInitializing(live.status) ? " (an initializing status this wait is for)" : ""}`
837
+ : "NO STATUS on that read";
838
+ const createdMs = Date.parse(live.createdAt ?? "");
839
+ if (Number.isNaN(createdMs)) {
840
+ return `it reads ${status}, age UNREADABLE (the row carried no parseable createdAt)`;
841
+ }
842
+ const ageSec = Math.max(0, Math.round((nowMs - createdMs) / 1000));
843
+ return `it reads ${status} and was created ${ageSec}s ago`;
844
+ }
845
+ /**
846
+ * Is the NAME route even open for this row — i.e. does it carry the name a re-run will look up?
847
+ *
848
+ * Reconcile has two match routes: `strategyName` (case-folded, non-empty on both sides) and a
849
+ * create key journaled by THIS box. This is the name comparison out of `resolveInstanceBinding`,
850
+ * and it is a NECESSARY condition for that route, never a sufficient one. Reconcile applies three
851
+ * further filters this function cannot see, all of them about rows other than the one held here:
852
+ * it drops `DEAD_STATUSES` before comparing, it refuses when the surviving list carries a foreign
853
+ * `skillName` stamp (`E_WALLET_OWNED_BY_OTHER_PACKAGE`), and it refuses when more than one
854
+ * candidate answers to the name (`E_STATE_AMBIGUOUS_WALLETS`). All three are executed and all three
855
+ * create nothing — so a `false` here means "the name route is closed", while a `true` means only
856
+ * "it is not closed by the name", which is why the sentence it feeds states its outcome as
857
+ * conditional rather than as a fact.
858
+ *
859
+ * A `false` is not hypothetical: the unnamed create retry sends no `strategyName` at all (it is
860
+ * `.optional()` in the MCP schema), so the backend names that wallet itself and the row comes back
861
+ * under a name this package will never derive.
862
+ */
863
+ export function reconcileCanMatchByName(wantedName, live) {
864
+ const wanted = wantedName.trim().toLowerCase();
865
+ return wanted !== "" && (live.strategyName ?? "").trim().toLowerCase() === wanted;
866
+ }
867
+ /**
868
+ * The money invariant, and the executed boundary it holds at.
869
+ *
870
+ * `resolveInstanceBinding` matches by NAME first, and a non-empty match ends in `applyCandidates` —
871
+ * which adopts, waits or refuses and has no create in it. Only `gateOrCreate` sets `needsWallet`,
872
+ * and it is reached only when NO live row answers to the name this deploy derives. So this is not a
873
+ * prediction about the row in hand: it is a property of the name route, and it was executed rather
874
+ * than reasoned — 180 second `runDeploy`s over a live row under that name (status × stamp × sibling
875
+ * count × address readable/blank × journal present/absent) created nothing, split across `ok` binds
876
+ * and three different refusal codes, and so did every payload where this row was dead or under
877
+ * another name while a live row still answered to the derived one.
878
+ *
879
+ * WHICH of those answers a re-run gives is exactly what this builder cannot know, so it is not
880
+ * stated. The create count is the part that spends money, and it is the part that holds.
881
+ *
882
+ * `carriesTheName` is the read: this row's own name is the one a re-run looks up. Without a row
883
+ * (the poll's last read returned none) the invariant still holds — it is about the name, not about
884
+ * this row — so the clause that asserts something about the row is simply dropped.
885
+ *
886
+ * Both halves are scoped to THIS strategy, and that scope is the whole of what the boundary
887
+ * covers. The command is package-wide: `deploy` runs every sleeve, and a sleeve with no live row
888
+ * under its own derived name gets a wallet — correct behaviour, and it means a sentence whose
889
+ * subject is the command is false on any multi-sleeve package. Executed on a two-sleeve package
890
+ * (`main` 0.6 / `scalp` 0.4, budget 250) whose `main` sleeve renders this very sentence: re-running
891
+ * exactly the command it names creates `spider-scalp` at `initialBudget: 100` beside a live sibling
892
+ * row, and again when that row is dead or absent (only an ours-stamped sibling refuses) — while
893
+ * `spider-main` is bound, not created, on all four. So the promise is stated per strategy: this
894
+ * strategy is bound or stopped, and no second wallet appears for it.
895
+ */
896
+ function noSecondWalletWhileNamedLive(resume, carriesTheName) {
897
+ const lead = carriesTheName
898
+ ? `This strategy carries the name a re-run looks up, and while a live strategy still answers to ` +
899
+ `that name, `
900
+ : `While a live strategy answers to the name a re-run looks up, `;
901
+ return (`${lead}re-running ${resume} binds this strategy to what it finds there or stops with a code — ` +
902
+ `never creating a second wallet for it.`);
903
+ }
904
+ /**
905
+ * The same invariant read from the other side, for a row the name route cannot reach.
906
+ *
907
+ * The unnamed create retry sends no `strategyName`, so the backend names that wallet and the name
908
+ * this package derives will never hit it. The protection above is about that name, and this row is
909
+ * not under it — executed: names differ, journal absent, and the stamp missing from the read
910
+ * (starvation; both create calls send `skillName: pkg.id`, `:3252` and `:3294`, so a stampless read
911
+ * is a producer fault rather than a coin flip) answers `reconcile: ok` with ONE create, a second
912
+ * funded wallet standing beside this live row. Six such rows executed, across the statuses and
913
+ * addresses this builder can render.
914
+ *
915
+ * Stated as what it CAN do, because whether it does turns on the journal — a file on disk, read by
916
+ * the next process, which this render path cannot consult. That is what the triage step is for.
917
+ *
918
+ * The protection it quotes is scoped per strategy for the same reason the sentence above is: the
919
+ * command covers every sleeve in the package, so "a re-run funds no second wallet" as a statement
920
+ * about the COMMAND is false on any multi-sleeve package (executed — see
921
+ * {@link noSecondWalletWhileNamedLive}).
922
+ */
923
+ function secondWalletRiskWhenNameRouteClosed(resume) {
924
+ return (`A re-run funds no second wallet for a strategy while a live strategy answers to the name it ` +
925
+ `looks up — this strategy is not under that name, so that does not cover it: re-running ` +
926
+ `${resume} can fund a SECOND wallet beside this live one.`);
927
+ }
928
+ /**
929
+ * Facts that change what a re-run does — named, with no outcome attached to any of them, and with
930
+ * no claim that naming them closes the question.
931
+ *
932
+ * Each is a fact about rows this process did not read, or about a field it read as absent. Four
933
+ * rounds attached outcomes to them and each round's enumeration was falsified by the payload beside
934
+ * the one it was written from, including along these last two axes, which the stated conditions
935
+ * never named at all. Naming the facts is what this builder can do; naming their outcomes is not.
936
+ *
937
+ * A fifth round then attached a COUNT to them ("Four facts decide the answer"), which is a closure
938
+ * claim and is false on the names-differ arms: the create key journaled on this box decides those,
939
+ * and this builder takes no journal argument, so it renders the same bytes either way. Executed —
940
+ * identical row, byte-identical detail: journal absent → one create, journal present → none. The
941
+ * journal is not listed here for exactly that reason (a fact this render cannot read is not
942
+ * context, it is a guess); what is dropped instead is the pretence that the list is complete.
943
+ */
944
+ const FACTS_THAT_DECIDE = `Facts that change the answer, none of them settled by this read: whether another live strategy ` +
945
+ `answers to that name, whether one under it carries a different package's stamp, whether this ` +
946
+ `strategy has died, and whether its wallet address has become readable.`;
947
+ /**
948
+ * The system invariant behind every wallet sentence on this surface, and who resolves a breach.
949
+ *
950
+ * One wallet per strategy; two is the failure mode. That makes a duplicate recoverable rather than
951
+ * catastrophic — both wallets are ordinary rows in `strategy_list` — but only for a reader who is
952
+ * told what the duplicate looks like and that choosing between the two is not this deploy's call.
953
+ * Closing is the destructive act, so an agent picking the wrong row closes a funded wallet; that is
954
+ * why this stands immediately before the never-close line and states no outcome of its own.
955
+ *
956
+ * The invariant and whose call a breach is hold on every payload. Where to LOOK does not: on the
957
+ * degraded read that returned no row, pointing at `strategy_list` for the duplicate contradicts the
958
+ * lead this same detail opens with, and an agent that triages, sees one row, and reads that as "no
959
+ * duplicate" re-runs — funding a wallet at the full budget. So that clause renders only where a row
960
+ * was actually read.
961
+ */
962
+ function oneWalletPerStrategy(rowWasRead) {
963
+ // The locator is dropped where the read this detail was built from returned no row. Saying "two
964
+ // wallets show as two rows in `strategy_list`" beside a lead that just reported no row there
965
+ // reads as "the list is authoritative and it shows one" — an agent that triages, sees nothing,
966
+ // and concludes there is no duplicate re-runs, and that re-run funds a wallet at the full budget.
967
+ // The invariant and whose call it is hold on every payload; only the where-to-look does not.
968
+ const whoDecides = rowWasRead
969
+ ? `Two wallets show as two rows in \`strategy_list\`, and which one to keep is the user's call.`
970
+ : `Which one to keep is the user's call.`;
971
+ return `One wallet per strategy is the invariant, and two is the failure mode. ${whoDecides}`;
972
+ }
973
+ /**
974
+ * The one certain next step, on every arm, and the one act that must never follow.
975
+ *
976
+ * Read-only triage is the only instruction here that is true regardless of what the next read
977
+ * finds, and close/recreate is the only genuinely destructive act available on this surface — it
978
+ * throws away a wallet that may already be funded and live.
979
+ *
980
+ * `strategy_list` is named alone. `openclaw senpi status` used to stand beside it as an
981
+ * alternative "to establish those", and it establishes none of them: it renders
982
+ * `RuntimeHealthStatus` for runtimes installed HERE — runtime id, health, log level, scanners, DSL,
983
+ * actions — and reads no backend strategy row, so it carries no name, no status, no package stamp
984
+ * and no wallet address. Executed through the CLI action: on this payload the install step never
985
+ * ran, so the gateway collects no handles and the whole output is `No running runtime health
986
+ * status.`; on the richest payload it CAN render, every line is about a local runtime. An agent
987
+ * that took the cheaper option would have learned nothing and could count the mandatory triage step
988
+ * as discharged.
989
+ */
990
+ function triageThenNeverClose(rowWasRead) {
991
+ return (`Read-only triage first — \`strategy_list\` — to establish those before re-running anywhere. ` +
992
+ `${oneWalletPerStrategy(rowWasRead)} NEVER close or recreate to "start clean".`);
993
+ }
994
+ /** The name this row actually carries versus the one this deploy asked for, when they differ. */
995
+ function polledNameMismatchClause(wantedName, live) {
996
+ const wanted = wantedName.trim();
997
+ const has = (live.strategyName ?? "").trim();
998
+ const asked = wanted ? `the "${wanted}" this deploy asked for` : "any name this deploy can look it up by";
999
+ return has ? `, under the name "${has}", not ${asked}` : `, under no readable name of its own, not ${asked}`;
1000
+ }
1001
+ /**
1002
+ * The bounded wait ran out. What this renders is what it READ, plus the one thing it can promise.
1003
+ *
1004
+ * The poll's success test is ACTIVE **and** a readable wallet address; everything else that is not
1005
+ * ACTIVE is intercepted before the deadline (dead → `failed`, neither-ACTIVE-nor-initializing →
1006
+ * `failed`), and reconcile refuses those statuses one screen earlier. So what lands here is either
1007
+ * a row still on its way to ACTIVE, or a row that already READS ACTIVE whose address could not be
1008
+ * read — plus the degenerate reads (no row at all, unparseable `createdAt`), which keep the
1009
+ * initializing branch's wording because they establish nothing to say instead. The two payloads
1010
+ * differ in what is MISSING, and that is the one place they diverge here: ACTIVE for the first, the
1011
+ * wallet address for the second.
1012
+ *
1013
+ * It states no outcome for a re-run beyond the money invariant, and the reason is structural. This
1014
+ * builder holds ONE row, read by id. How many live rows answer to the name a re-run looks up, what
1015
+ * stamps THOSE rows carry, whether a create key journaled on this box still resolves, and whether
1016
+ * the address becomes readable are facts about things it did not read and must not read here — a
1017
+ * detail that can throw is a detail that sometimes does not render. Four rounds enumerated those
1018
+ * outcomes anyway: the enumeration grew to 16 predicted branches across the four arms and was still
1019
+ * false on six executed payloads, twice along axes its stated conditions never named. Predicting
1020
+ * from a read that cannot see the deciding facts does not converge, so this renders none of it.
1021
+ *
1022
+ * What survives is {@link noSecondWalletWhileNamedLive} / {@link secondWalletRiskWhenNameRouteClosed}
1023
+ * — one class statement about money per arm, scoped to this strategy and executed at its boundary
1024
+ * rather than reasoned — then {@link FACTS_THAT_DECIDE}, which names facts WITHOUT attaching
1025
+ * outcomes to them or claiming to have listed them all, and {@link triageThenNeverClose}, which
1026
+ * carries the only next step that is correct whatever the next read finds, the shape of the failure
1027
+ * this is all about ({@link oneWalletPerStrategy}), and the one act that must never follow. Every
1028
+ * arm ends on those two, because the tail is what a reader in a hurry acts on.
1029
+ */
1030
+ export function buildCreatePendingDetail(pkg, budget, waitedSec, live, nowMs, wantedName) {
1031
+ const resume = `\`${resumeCommand(pkg.dir, budget)}\``;
1032
+ const byName = !live || reconcileCanMatchByName(wantedName, live);
1033
+ const nameClause = live && !byName ? polledNameMismatchClause(wantedName, live) : "";
1034
+ const money = byName
1035
+ ? noSecondWalletWhileNamedLive(resume, !!live)
1036
+ : secondWalletRiskWhenNameRouteClosed(resume);
1037
+ const tail = `${money} ${FACTS_THAT_DECIDE} ${triageThenNeverClose(!!live)}`;
1038
+ if (live && live.status.toUpperCase() === "ACTIVE") {
1039
+ return (`after ${waitedSec}s this strategy still has no readable wallet address — ` +
1040
+ `${polledRecordRead(live, nowMs)}${nameClause}: the status this wait was for is already ` +
1041
+ `reached, the wallet address is the field still missing, and an ACTIVE strategy whose ` +
1042
+ `address cannot be read may be funded and live. ${tail}`);
1043
+ }
1044
+ return (`still waiting after ${waitedSec}s for this strategy to read ACTIVE with a wallet address — ` +
1045
+ `${polledRecordRead(live, nowMs)}${nameClause}. ACTIVE is the state this wait is still ` +
1046
+ `missing. ${tail}`);
1047
+ }
1048
+ /**
1049
+ * Live wallets carry this package's stamp, and none of them answered for this instance.
1050
+ *
1051
+ * Rendered from the reconcile read alone — no additional network calls, because a gate that can
1052
+ * fail to render is a gate that sometimes does not fire. Every column is quoted from that read and
1053
+ * says so when it is unreadable: an unread funded amount is the one number a reader would otherwise
1054
+ * take for an empty wallet. The name column is there because a missing/differing name is WHY the
1055
+ * match failed, and it is the fact that tells a reader whether this is old-tooling residue or a
1056
+ * backend that renamed what deploy created.
1057
+ *
1058
+ * The destructive route is named LAST and behind the read-only one, with its real blast radius on
1059
+ * the same line: a refusal that names only a teardown gets followed.
1060
+ *
1061
+ * The consequence sentence is hedged for the same reason the columns are: this read is metadata,
1062
+ * so "left funded, in position and supervised by nothing" would assert two things nothing here
1063
+ * looked at (no position read, no `registryFindById`/`runtimeIsLive` call) — and the second can be
1064
+ * plainly false, since install rebinds only the registry row for `<id>-<instance>` and a wallet
1065
+ * carrying a different runtime id keeps its runtime. What is unread is named as unread; that is
1066
+ * the argument for stopping, not a weaker version of it.
1067
+ */
1068
+ export function buildBindingUnknownRefusal(pkg, instance, wantedName, candidates, budget) {
1069
+ const rows = candidates
1070
+ .map((s) => {
1071
+ const wallet = s.strategyWalletAddress?.trim() || "(wallet address unreadable)";
1072
+ const funded = s.totalFunded === undefined ? "funded amount unreadable" : usd(s.totalFunded);
1073
+ const created = s.createdAt ? `created ${s.createdAt.slice(0, 10)}` : "creation date unreadable";
1074
+ const named = s.strategyName?.trim() ? `named "${s.strategyName.trim()}"` : "no name reported";
1075
+ return ` ${wallet} ${funded} ${s.status} ${created} ${named}`;
1076
+ })
1077
+ .join("\n");
1078
+ return (`[E_INSTANCE_BINDING_UNKNOWN] ${pkg.id}: ${candidates.length} live wallet(s) carry this package's ` +
1079
+ `stamp, and none of them answers for instance "${instance}".\n\n${rows}\n\n` +
1080
+ `Nothing was created and no money moved. Deploying now would fund a NEW wallet beside the ` +
1081
+ `one(s) above and rebind instance "${instance}"'s runtime id onto it. This read is metadata ` +
1082
+ `only: whether the wallet(s) above hold open positions, and whether any runtime is still ` +
1083
+ `watching them, was NOT read here — which is exactly why deploying past it is a bet rather ` +
1084
+ `than a decision.\n\n` +
1085
+ `Read what each wallet is doing before deciding anything:\n` +
1086
+ ` \`python3 senpi-strategy-ops/scripts/status.py '${pkg.id}'\`\n\n` +
1087
+ `Deploy binds an existing strategy two ways — by the name instance "${instance}" answers to ` +
1088
+ `("${wantedName}"), and by a create key journaled on this box — and neither reached the ` +
1089
+ `wallet(s) above. If one of them IS this instance under a name deploy does not know, this verb ` +
1090
+ `has no way to bind to it, so re-running changes nothing. What each wallet is and whether it is ` +
1091
+ `still wanted is the USER's call, made from the read above — deploy will not pick one. Only if ` +
1092
+ `the USER confirms these wallets are unwanted, close them: ` +
1093
+ `\`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\` returns their funds and tears down ` +
1094
+ `the WHOLE ${pkg.id} package, every sleeve and runtime in it. Then re-run ` +
1095
+ `\`${resumeCommand(pkg.dir, budget)}\`.`);
1096
+ }
1097
+ /**
1098
+ * Every live strategy under the name this instance answers to is stamped for a DIFFERENT package.
1099
+ *
1100
+ * The counterpart to {@link buildBindingUnknownRefusal} on the other match route, and the harder of
1101
+ * the two to word: nothing here is the operator's to close. The wallets named belong to another
1102
+ * package — its positions, its balance, possibly its running runtime — so the only routes offered
1103
+ * are edits to THIS package and reads. Re-running is explicitly not one of them: the name is
1104
+ * derived, so it comes out identical every time, which is exactly why the collision is structural
1105
+ * rather than transient.
1106
+ *
1107
+ * Neither is "set `id:` back to the stamp and re-run", which this refusal used to offer. The name
1108
+ * is derived FROM the id ({@link walletName}: `sanitizeStrategyName(id)`, or `<id>-<instance>` for
1109
+ * a multi-sleeve package), and a loadable id is sanitize-stable by construction
1110
+ * (`assertIdentityRoundTrips`), so changing the id to the stamp changes the derived name by exactly
1111
+ * as much — the run after the edit looks for a name the matched wallet does not carry, finds
1112
+ * nothing, and refuses again from the gate instead, whose remedy is the whole-package `close.py`
1113
+ * this refusal's last line forbids. Only the id AND the sleeve layout restored together re-derive
1114
+ * the matched name, which is a restructure of this package, not a field edit — so the route ends at
1115
+ * the read and hands the decision to the user.
1116
+ */
1117
+ export function buildForeignPackageRefusal(pkg, instance, wantedName, candidates, budget) {
1118
+ const rows = candidates
1119
+ .map((s) => {
1120
+ // Same absence rules as every other rendered candidate list: an unread field says so rather
1121
+ // than rendering as a blank or a `$0.00` the reader would act on.
1122
+ const wallet = s.strategyWalletAddress?.trim() || "(wallet address unreadable)";
1123
+ const funded = s.totalFunded === undefined ? "funded amount unreadable" : usd(s.totalFunded);
1124
+ const claimant = s.skillName?.trim() || "(stamp unreadable)";
1125
+ return ` ${wallet} ${funded} ${s.status} stamped "${claimant}"`;
1126
+ })
1127
+ .join("\n");
1128
+ // Deduped, because the sentence names WHOSE they are and repeating one id per row reads as more
1129
+ // packages than were found.
1130
+ const stamps = [...new Set(candidates.map((s) => s.skillName?.trim()).filter((v) => !!v))];
1131
+ const whose = stamps.length > 0 ? stamps.map((s) => `"${s}"`).join(", ") : "another package";
1132
+ // Computed from the package as loaded, not templated: a flat package's name IS its id, and
1133
+ // telling its author to rename an instance it does not have is an edit they cannot make.
1134
+ const multi = pkg.instances.length > 1;
1135
+ const derivedFrom = multi
1136
+ ? `package id "${pkg.id}" + instance name "${instance}"`
1137
+ : `package id "${pkg.id}"`;
1138
+ const renameWhat = multi ? "the package `id:` or this instance's `name:`" : "the package `id:`";
1139
+ // A stamp is BACKEND text, not a package id this box loaded, so it is not shell-safe by
1140
+ // construction the way `pkg.id` is (`assertIdentityRoundTrips` guarantees that one). A stamp that
1141
+ // is not a legal package id is named in prose instead of pasted into a command — an emitted
1142
+ // command has to be runnable, and one carrying a quote is worse than none.
1143
+ const reads = stamps
1144
+ .map((s) => sanitizeStrategyName(s) === s
1145
+ ? ` \`python3 senpi-strategy-ops/scripts/status.py '${s}'\``
1146
+ : ` (no command for the stamp ${JSON.stringify(s)} — it is not a usable package id; look ` +
1147
+ `the wallet up by address with \`openclaw senpi status\` / \`strategy_list\`)`)
1148
+ .join("\n");
1149
+ return (`[E_WALLET_OWNED_BY_OTHER_PACKAGE] ${pkg.id}: instance "${instance}" answers to the strategy ` +
1150
+ `name "${wantedName}", and every live strategy under that name carries another package's ` +
1151
+ `attribution stamp (${whose}).\n\n${rows}\n\n` +
1152
+ `Nothing was created and no money moved. Deploy binds an existing strategy BY NAME, so ` +
1153
+ `adopting one of the above would have pointed this package's runtime at another package's ` +
1154
+ `funded wallet — its balance and its open positions — and, if something is already watching ` +
1155
+ `that wallet, left two exit engines on one margin account. This read is metadata only: what ` +
1156
+ `those wallets hold, and whether anything is watching them, was NOT read here.\n\n` +
1157
+ `Re-running changes nothing — "${wantedName}" is derived from ${derivedFrom}, so it comes out ` +
1158
+ `the same every time. Two ways forward, neither of them a money move:\n\n` +
1159
+ ` • The wallet(s) above are not this package's. Two packages collide on one derived name: ` +
1160
+ `give this one a name of its own by editing ${renameWhat} in ` +
1161
+ `${join(pkg.dir, "strategy.yaml")}, then re-run \`${resumeCommand(pkg.dir, budget)}\` — deploy ` +
1162
+ `will create this package's own wallet.\n` +
1163
+ ` • One of them IS this package's, deployed under a package id it no longer has. Editing ` +
1164
+ `\`id:\` back to the stamp does NOT reach it: "${wantedName}" is derived from ${derivedFrom}, ` +
1165
+ `so that edit moves the name deploy looks for along WITH the id — the next run stops looking ` +
1166
+ `for "${wantedName}" at all and refuses again, having bound nothing. What produced ` +
1167
+ `"${wantedName}" is the id and the sleeve layout together, so restoring both as they were is ` +
1168
+ `what would bind it by name; whether this package should go back to that shape is the USER's ` +
1169
+ `call, and a metadata read cannot settle it. Read what each wallet is doing first:\n` +
1170
+ `${reads}\n\n` +
1171
+ `Do NOT close a wallet stamped for another package to clear this — that is a different ` +
1172
+ `strategy's funds and a different runtime's positions. Whose these are is the USER's call.`);
1173
+ }
1174
+ /**
1175
+ * Refusal for a package whose instances ship no DSL exit block. Rendered BEFORE any wallet exists:
1176
+ * funding a strategy that cannot stop itself out is the "funded-but-no-DSL" hole, and the skills-side
1177
+ * validator has always refused it.
1178
+ */
1179
+ export function buildNoDslExitRefusal(instances, pkg) {
1180
+ return (`[INVALID_REQUEST] Instance(s) ${instances.map((inst) => inst.name).join(", ")} declare no DSL ` +
1181
+ `exit block (\`exit.dsl_preset\`, or \`exit.engine: dsl\`) — every position they open would run ` +
1182
+ `with no stop loss and no trailing floor. Nothing was created. Add an exit block to the instance ` +
1183
+ `runtime.yaml, then re-check read-only with either route:\n` +
1184
+ // One `senpi validate` per OFFENDING instance directory, computed from the instance rather than
1185
+ // templated: the offence is per-recipe, and so is the proof that clears it.
1186
+ `${recheckBlock(instances.map((inst) => inst.runtimeYamlDir), deployPyValidateCommand(pkg.dir))}\n` +
1187
+ `${PROOF_ROUTE_NOTE} Then re-run.`);
1188
+ }
1189
+ /**
1190
+ * A recipe that does not bind the wallet its manifest promises, refused before a wallet exists.
1191
+ *
1192
+ * Names the two addresses side by side because the failure is precisely that they are two: the
1193
+ * money this run would move, and the account the strategy would trade. It states the edit rather
1194
+ * than a command, in the {@link buildInstanceDirCollisionRefusal} spirit — the fix is one line in a
1195
+ * file the reader already has open, and no command can make it for them.
1196
+ */
1197
+ export function buildUnboundWalletRefusal(faults, pkg) {
1198
+ const rows = faults.map((fault) => ` - "${fault.instance.name}" (${fault.instance.runtimeYamlPath}) binds ` +
1199
+ `${fault.declared === undefined ? "no strategy.wallet at all" : `\`${fault.declared}\``}, ` +
1200
+ `but strategy.yaml declares \`wallet_env: ${fault.instance.walletEnv}\` for it`);
1201
+ return (`[INVALID_REQUEST] ${faults.length} instance(s) do not bind the wallet this package says they ` +
1202
+ `bind:\n${rows.join("\n")}\n\n` +
1203
+ `Why: deploy CREATES and FUNDS a wallet for each instance and passes it in by substituting ` +
1204
+ `\`\${<wallet_env>}\` into the recipe. A recipe that does not carry that token gets nothing ` +
1205
+ `substituted — silently, because the unresolved-placeholder check only looks for \`\${...}\` ` +
1206
+ `left over, and a hardcoded address leaves none. The deploy would fund a new wallet from your ` +
1207
+ `budget and then install the strategy, actions and exit engine included, against a DIFFERENT ` +
1208
+ `account: the funded wallet would sit with nothing watching it while the strategy traded ` +
1209
+ `elsewhere, and the install step would name the recipe's address, so the report would read ` +
1210
+ `clean.\n\n` +
1211
+ `Nothing was created — this gate reads the package's own files and stopped before any live ` +
1212
+ `read.\n\n` +
1213
+ `Fix — in each recipe above set \`strategy.wallet\` to the token strategy.yaml declares for ` +
1214
+ `that instance (for example \`wallet: "${faults[0].expected}"\` in ` +
1215
+ `${faults[0].instance.runtimeYamlPath}). Deploy substitutes the wallet it creates; a literal ` +
1216
+ `address there is never what you want on this path. Then re-check read-only with either ` +
1217
+ `route:\n` +
1218
+ `${recheckBlock(faults.map((fault) => fault.instance.runtimeYamlDir), deployPyValidateCommand(pkg.dir))}\n` +
1219
+ `${PROOF_ROUTE_NOTE} Then re-run.`);
1220
+ }
1221
+ /**
1222
+ * A package id carrying capitals, refused before any wallet exists to carry the wrong stamp.
1223
+ *
1224
+ * Deploy's copy of `register.ts`'s gate — same rule, same fix (built from the one producer in
1225
+ * `package.ts`, so the edit an author reads is word-for-word identical), different standing
1226
+ * sentence: nothing was CREATED here, where the gateway's copy could only say nothing was started.
1227
+ *
1228
+ * The re-run command names the directory the fix LEAVES BEHIND, not the one that failed — this is
1229
+ * the only refusal in the verb whose own instruction can move the package.
1230
+ */
1231
+ export function buildNonLowercaseIdRefusal(pkg, budget) {
1232
+ const fix = lowercasePackageIdFix(pkg.id, pkg.dir);
1233
+ return (`[INVALID_REQUEST] strategy.yaml id "${pkg.id}" must be lowercase. Nothing was created. ` +
1234
+ `Fix — ${fix.instruction}. Why: this verb writes the id into each wallet's \`skillName\` ` +
1235
+ `attribution stamp VERBATIM at create, and the backend stores what it is given ` +
1236
+ `case-normalized, so a mixed-case id is stamped under one spelling and looked up under ` +
1237
+ `another — that is how \`close.py '${pkg.id}'\` came to report "no OPEN strategies to close." ` +
1238
+ `over a live, funded, trading wallet. Every reader now folds case, so wallets already stamped ` +
1239
+ `in any case are still found and this package will ADOPT the ones it already owns rather than ` +
1240
+ `fund a second set beside them. Then re-run \`${resumeCommand(fix.dirAfterFix, budget)}\`.`);
1241
+ }
1242
+ /**
1243
+ * The refusal for a manifest whose funding shares allocate more than the whole budget.
1244
+ *
1245
+ * States the arithmetic and nothing about the outcome: the sum, every sleeve's effective share, and
1246
+ * the amount the plan would have moved against the amount authorized. A reader can check each
1247
+ * figure against the file named, which is the point — the defect is a typo in one line of
1248
+ * `strategy.yaml`, and the fastest fix is seeing which share is wrong.
1249
+ *
1250
+ * The fix is the author's, and only they can make it: which sleeve should be smaller is a decision
1251
+ * about the strategy, not one this gate can compute. So it names the constraint (the shares are a
1252
+ * split of ONE budget) and the file, and offers no edit of its own — a hint naming a particular
1253
+ * share would be a guess presented as an instruction. Deliberately no teardown route either:
1254
+ * nothing was created, so there is nothing to tear down.
1255
+ */
1256
+ export function buildShareOverrunRefusal(overrun, pkg, budget) {
1257
+ const rows = overrun.shares
1258
+ .map((row) => `${row.name} ${budgetArg(row.share)} (${usd(budget * row.share)})`)
1259
+ .join(", ");
1260
+ return (`[E_FUNDING_SHARES_OVER_ONE] The funding shares in ${join(pkg.dir, "strategy.yaml")} sum to ` +
1261
+ `${budgetArg(overrun.sum)}, not 1 — so this deploy would fund ` +
1262
+ `${usd(budget * overrun.sum)} against the ${usd(budget)} you authorized. ` +
1263
+ `Nothing was created and no money moved. Shares: ${rows}. ` +
1264
+ `Fix — a \`funding_share\` is that sleeve's slice of ONE budget, so the values must sum to 1 ` +
1265
+ `across the package (an undeclared share is an even split of what is left). Correct the ` +
1266
+ `share(s) in ${join(pkg.dir, "strategy.yaml")}, then re-run ` +
1267
+ `\`${resumeCommand(pkg.dir, budget)}\`. ` +
1268
+ `Why this is refused rather than clamped: the amounts a share produces are what each wallet ` +
1269
+ `TRADES, so scaling them down here would size every position off a number you never chose.`);
1270
+ }
1271
+ /**
1272
+ * The install gate's refusal, rendered for deploy rather than for the gateway.
1273
+ *
1274
+ * Same findings, different standing sentence: the gateway's copy says a recipe cannot be installed,
1275
+ * which is all that is true there. Here the run had money to move and did not move it, and that is
1276
+ * the fact a reader needs first — so it is stated, and the re-check names `senpi validate` on the
1277
+ * instance directory rather than "run validate on the package" in the abstract.
1278
+ *
1279
+ * Deploy's refusal shape: bracketed code first (taxonomy.ts:16 reads it), then what/why/fix.
1280
+ */
1281
+ export function buildInstallGateRefusal(gate, inst, pkg, budget) {
1282
+ const lines = gate.blocking.map((f) => ` • ${f.what}\n why: ${f.why}\n fix: ${f.fix}`);
1283
+ return (`[${gate.blocking[0].code}] "${inst.name}" cannot be installed — it would not trade.\n\n` +
1284
+ `${lines.join("\n\n")}\n\n` +
1285
+ `Nothing was created by this run — the check reads the package only, and it stopped before ` +
1286
+ `touching the backend.\n\n` +
1287
+ `Fix the recipe, then re-check with: ${validateCommand(inst.runtimeYamlDir)}\n` +
1288
+ // The same command `next` ends the report with, budget and all: a re-run line that differs
1289
+ // from it — a `<usd>` placeholder where the report has the real number — is a second, worse
1290
+ // command competing with the authoritative one.
1291
+ `Then re-run: ${resumeCommand(pkg.dir, budget)}`);
1292
+ }
1293
+ /**
1294
+ * The gate itself could not run. NOT a verdict on the recipe — so it carries no taxonomy code and
1295
+ * names no offending field, exactly like the universe check's `unavailable` branch.
1296
+ */
1297
+ export function buildInstallGateUnavailableDetail(inst, reason) {
1298
+ return (`The install gate could not run for "${inst.name}" (${reason}), so nothing is known about ` +
1299
+ `whether this recipe can trade. Nothing was created by this run. Re-run the deploy; if it ` +
1300
+ // The real directory, never a `<package dir>` placeholder: this line is the one command the
1301
+ // reader is given, and a command that has to be edited before it runs is a command an agent
1302
+ // pastes literally. The INSTANCE's directory, not the package root: the gate ran for one
1303
+ // sleeve, and on a multi-instance package the root is not a validate target at all — it
1304
+ // answers "this directory has no recipe of its own, pick an instance".
1305
+ `persists, run: ${validateCommand(inst.runtimeYamlDir)}`);
1306
+ }
1307
+ /**
1308
+ * D-2 (revised 2026-08-05): a scanner-level `enabled` key is refused outright — the engine never
1309
+ * reads it (see {@link scannersDeclaringEnabled}), so accepting `enabled: false` would ship a
1310
+ * strategy that ticks and trades while its author believes it is switched off. Package-derivable,
1311
+ * so it is decided synchronously here rather than discovered later; observe keeps its own
1312
+ * all-disabled branch for the live-state case, which the runtime API can genuinely produce.
1313
+ *
1314
+ * Every offense is enumerated in ONE refusal, each as a delete-this-line instruction naming the
1315
+ * key, the scanner, the instance and the file: a reader (agent or human) fixes the whole package in
1316
+ * one pass instead of re-running to discover the next one.
1317
+ */
1318
+ export function buildScannerEnabledUnsupportedRefusal(offenders, pkg) {
1319
+ return (`[INVALID_REQUEST] Unsupported \`enabled\` key on ${offenders.length} scanner entr` +
1320
+ `${offenders.length === 1 ? "y" : "ies"}. Nothing was created. Fix — delete these lines:\n` +
1321
+ offenders.map((entry) => ` - ${describeScannerEnabledOffense(entry)}`).join("\n") +
1322
+ `\nWhy: the runtime engine never reads a scanner-level \`enabled\` (the scanner schema passes ` +
1323
+ `it through untyped and registration comes from the strategy, not the scanner entry), so an ` +
1324
+ `\`enabled: false\` scanner registers ENABLED and ticks — the package would trade while you ` +
1325
+ `believe it is off. A scanner runs because the package declares it: to stop one, remove the ` +
1326
+ `scanner entry itself, or disable it after install through the runtime API (\`disableScanner\`). ` +
1327
+ `Then re-run: ${resumeCommand(pkg.dir)}`);
1328
+ }
1329
+ /**
1330
+ * A create this job stopped waiting on at its per-call deadline. Deliberately NOT phrased as
1331
+ * "creation failed": the deadline bounds the WAIT, not the backend's work, so the wallet may come
1332
+ * into existence after this line is written. Naming both reconcile routes — and the one case
1333
+ * neither can see — is what lets the operator check rather than guess.
1334
+ */
1335
+ export function buildCreateAbandonedDetail(args) {
1336
+ const { instance, strategyName, nameFallbackFired, fundAmount, message, pkg, budget } = args;
1337
+ const head = `wallet creation for instance "${instance}" was abandoned at its deadline, so its outcome is ` +
1338
+ `UNKNOWN — this is not "nothing happened": ${message ?? "no message"}. The backend may still ` +
1339
+ `complete the create and fund ${usd(fundAmount)} after this report. Nothing else was done for ` +
1340
+ `this instance, and nothing was rolled back.`;
1341
+ // Which create was abandoned is KNOWN, so neither branch hedges. A named create that lands is
1342
+ // findable by name; the unnamed retry is findable by nothing — it lands under a backend-assigned
1343
+ // name and this box never received the strategyId to journal. In that case re-running FIRST is
1344
+ // the money-losing move, so the check leads.
1345
+ const tail = nameFallbackFired
1346
+ ? ` The name "${strategyName}" had already been rejected, so this second create asked for NO ` +
1347
+ `name: if it landed, the wallet carries a backend-assigned name and no create key ever ` +
1348
+ `reached this box — a re-run can find it by neither route and would fund a SECOND wallet ` +
1349
+ `beside it. Check before you re-run: list your strategies (MCP \`strategy_list\`) and look ` +
1350
+ `for a CUSTOM strategy created just now, funded about ${usd(fundAmount)}. If it is there, ` +
1351
+ `that is this instance's wallet — keep it, or close it with ` +
1352
+ `\`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\` before re-running. If it is not ` +
1353
+ `there, the create did not land: re-run \`${resumeCommand(pkg.dir, budget)}\`.`
1354
+ : ` The create asked for the name "${strategyName}", so if it landed the wallet carries that ` +
1355
+ `name and reconcile adopts it instead of funding a second one: re-run ` +
1356
+ `\`${resumeCommand(pkg.dir, budget)}\`. If that re-run creates a fresh wallet and your ` +
1357
+ `accessible balance looks short afterwards, check the wallet list (MCP \`strategy_list\`) ` +
1358
+ `and close the orphan: \`python3 senpi-strategy-ops/scripts/close.py '${pkg.id}'\`.`;
1359
+ return head + tail;
1360
+ }
1361
+ /**
1362
+ * The name-rejection retry that was NOT issued because the deadline stop landed between the two
1363
+ * creates.
1364
+ *
1365
+ * Not a variant of {@link buildCreateAbandonedDetail}: nothing was abandoned mid-call here. One
1366
+ * create was issued and the backend answered it; the second one — the money-moving call this run
1367
+ * would otherwise have made with no step boundary in front of it — never went out. So the line says
1368
+ * what was attempted, what was not, and leaves the rejected create's own residue to the same check
1369
+ * every rejection carries: reconcile re-reads the live list before it creates anything.
1370
+ */
1371
+ export function buildCreateNameRetryNotIssuedDetail(args) {
1372
+ const { instance, strategyName, fundAmount, message, pkg, budget } = args;
1373
+ return (`wallet creation for instance "${instance}" stopped at the deploy deadline. The backend rejected ` +
1374
+ `the name "${strategyName}" (${message ?? "no message"}), and the unnamed retry that normally ` +
1375
+ `follows was NOT issued: a create started now would take a fresh 90s deadline of its own, ` +
1376
+ `outliving the grace period that hands the deploy slot to the next run — this job could still ` +
1377
+ `be waiting on a create funding ${usd(fundAmount)} while a new deploy runs its own preflight ` +
1378
+ `against the same balance. One create was issued for this instance and the backend rejected it; ` +
1379
+ `no second wallet was asked for, and nothing was rolled back. Re-run ` +
1380
+ `\`${resumeCommand(pkg.dir, budget)}\` — reconcile re-reads your live strategies before it ` +
1381
+ `creates anything, and a wallet that landed under the name "${strategyName}" despite the ` +
1382
+ `rejection is exactly what it looks for.`);
1383
+ }
1384
+ export function buildUnobservedDetail(waitSec, runtimeId) {
1385
+ return (`no tick observed within ${waitSec}s — scanners may tick on a longer interval. ` +
1386
+ `Check in a few minutes: \`openclaw senpi scanner -r ${runtimeId}\``);
1387
+ }
1388
+ //# sourceMappingURL=messages.js.map