specrails-desktop 2.55.1 → 2.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/client/dist/assets/{ActivityFeedPage-DmJcQpNe.js → ActivityFeedPage-DdMNmVBk.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-NxBHrU-V.js → AgentBrowserCapture-CeerUf--.js} +1 -1
  3. package/client/dist/assets/{AgentModeAnalyticsPane-CGAKTJfU.js → AgentModeAnalyticsPane-BjGb-8rh.js} +2 -2
  4. package/client/dist/assets/{AgentModeCodePane-DNSs5_oe.js → AgentModeCodePane-8XRkQEo_.js} +2 -2
  5. package/client/dist/assets/{AgentModeJobsPane-DLoVYDnG.js → AgentModeJobsPane-BN3dgJOA.js} +1 -1
  6. package/client/dist/assets/{AgentsPage-CAzMn9Gg.js → AgentsPage-DfHWgP_x.js} +1 -1
  7. package/client/dist/assets/{AnalyticsPage-C06KT4Y0.js → AnalyticsPage-CyfhYiBn.js} +1 -1
  8. package/client/dist/assets/{CodePage-aX1mxGyT.js → CodePage-C2Nf-Q5W.js} +1 -1
  9. package/client/dist/assets/{DesktopAnalyticsPage-BtscfiQD.js → DesktopAnalyticsPage-tc0p4jJW.js} +1 -1
  10. package/client/dist/assets/{DocsDialog-CNOcXhp-.js → DocsDialog-Br3bczCp.js} +1 -1
  11. package/client/dist/assets/{DocsPage-C2BugxaI.js → DocsPage-BcDr2cBm.js} +1 -1
  12. package/client/dist/assets/{InteractiveJobComposer-CCncBqNu.js → InteractiveJobComposer-DFPpdg2D.js} +1 -1
  13. package/client/dist/assets/{JobDetailModal-Cn_k3-RM.js → JobDetailModal-BdOv-l-M.js} +1 -1
  14. package/client/dist/assets/{JobDetailPage-BogJFBsk.js → JobDetailPage-vBbNeRuz.js} +1 -1
  15. package/client/dist/assets/{JobsPage-DL4E8ZyU.js → JobsPage-CJCtPhIz.js} +1 -1
  16. package/client/dist/assets/{LoopBuilderPage-9L6URQLY.js → LoopBuilderPage-BemvUOxy.js} +1 -1
  17. package/client/dist/assets/{LoopPreviewModal-CAPqTIgL.js → LoopPreviewModal-C6hNAeYN.js} +1 -1
  18. package/client/dist/assets/{LoopsPage-aU8dYw7L.js → LoopsPage-BZr0quvh.js} +1 -1
  19. package/client/dist/assets/{PluginsPage-D_tsW9B5.js → PluginsPage-DOpTV75p.js} +1 -1
  20. package/client/dist/assets/{ProjectSettingsDialog-_ZbNuspP.js → ProjectSettingsDialog-Dkwrn0z1.js} +1 -1
  21. package/client/dist/assets/{ReviewPacketPage-CG-Dv0El.js → ReviewPacketPage-DcOq-UTA.js} +1 -1
  22. package/client/dist/assets/{TemplatePreviewModal-C19H2b2o.js → TemplatePreviewModal-Cejcquah.js} +1 -1
  23. package/client/dist/assets/{index-BcligJ3-.js → index-Cxb288Rj.js} +8 -8
  24. package/client/dist/index.html +1 -1
  25. package/docs/internals/adding-a-provider.md +7 -0
  26. package/docs/internals/core-runtime-updates.md +5 -0
  27. package/docs/internals/programmatic-agent-runtime.md +66 -0
  28. package/docs/internals/source-map.md +3 -1
  29. package/package.json +1 -1
  30. package/server/dist/mcp/guide.js +16 -2
  31. package/server/dist/mcp/tools/catalog.js +2 -0
  32. package/server/dist/mcp/tools/jobs.js +22 -4
  33. package/server/dist/mcp/tools/recovery.js +35 -0
  34. package/server/dist/mcp/tools/support.js +4 -1
  35. package/server/dist/modules/accounting/runtime/pricing.js +9 -2
  36. package/server/dist/modules/agent-runtime/runtime/agent-runtime-controls-router.js +16 -0
  37. package/server/dist/modules/agent-runtime/runtime/agent-runtime-controls.js +93 -1
  38. package/server/dist/modules/agent-runtime/runtime/agent-runtime-recovery.js +53 -0
  39. package/server/dist/modules/missions/runtime/agent-failure-briefing.js +4 -2
  40. package/server/dist/modules/missions/runtime/agent-operator-prompt.js +58 -5
  41. package/server/dist/modules/missions/runtime/mission-run-notify.js +4 -1
  42. package/server/dist/providers/claude-adapter.js +3 -2
@@ -204,7 +204,7 @@
204
204
  .specrails-splash__mark .pill { opacity: 1; }
205
205
  }
206
206
  </style>
207
- <script type="module" crossorigin src="/assets/index-BcligJ3-.js"></script>
207
+ <script type="module" crossorigin src="/assets/index-Cxb288Rj.js"></script>
208
208
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CNC7AqOf.js">
209
209
  <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-zu2_FqZY.js">
210
210
  <link rel="modulepreload" crossorigin href="/assets/react-dom-DiWzBDP9.js">
@@ -397,3 +397,10 @@ bundled `local-runner` process, which speaks claude-shaped stream-json so the
397
397
  spawn contract is unchanged. If you add another endpoint-backed provider, reuse
398
398
  that adapter factory + the runner rather than teaching managers a new
399
399
  transport. See `docs/internals/local-agent-runner.md`.
400
+
401
+ ### Claude Opus generation
402
+
403
+ The `opus` catalog value selects Claude Opus 5.5 (`claude-opus-5-5`). Keep the
404
+ Claude adapter pin, selector labels, Core CLI executor and cost estimates aligned
405
+ when advancing this alias. Concrete historical model IDs in usage events retain
406
+ their existing estimates; provider-reported cost remains authoritative.
@@ -12,6 +12,11 @@ framework is rejected rather than silently replacing an update.
12
12
 
13
13
  ## Persistence and publication
14
14
 
15
+ The release bundle pins Core 5.6.0 in `desktop-release.yml` and
16
+ `scripts/assemble-bundled-core.lock.json`. This includes scoped runtime recovery
17
+ and the Opus 5.5 alias. Update both pins together and check compatibility against
18
+ the staged published package; retained runs still use their original runtime.
19
+
15
20
  Desktop updates retain the complete npm installation, including dependencies,
16
21
  under `~/.specrails/core/<version>/`. The registry home override applies to this
17
22
  directory. Only the managed package corresponding to the active framework is
@@ -138,3 +138,69 @@ Core's additive runtime metrics v1 are passed from compact status to each saved
138
138
  Older Core versions continue to work without the panel. The server validates numeric fields and known phase IDs, drops unsupported/malformed metrics and projects only the supported fields; it never forwards arbitrary transcripts from the metrics object. Missing billing is displayed as unavailable rather than zero. Cache tokens are already included in input tokens, and agent duration already includes native tool work. The panel does not estimate savings or measure implementation quality.
139
139
 
140
140
  The paired `agent-runtime-efficiency` OpenSpec change in specrails-core documents verification ownership, efficient API tools and the measurement contract. Core exposes the same report through `runtime status` / `runtime-result`, so a fixed set of real tasks can be compared without a Desktop database migration.
141
+
142
+ ## Diagnose before retrying
143
+
144
+ `GET /agent-runtime/runs/:runId/diagnosis` (MCP: `specrails_jobs runtime_diagnose`)
145
+ returns the original scope, historical completed steps, up to eight recent
146
+ failed/interrupted attempts, verification and acceptance reasons, and a recovery
147
+ recommendation. It performs no provider calls or mutations. Repeated matching
148
+ step/error pairs recommend repairing the precondition before retrying. A missing
149
+ history on an older retained Core is unknown, not zero failures.
150
+
151
+ MCP `runtime_evidence` accepts `evidenceId`, `section`, `sourceId`, `cursor` and
152
+ `limit` to inspect the actual evidence beyond its index. `canResume` only means
153
+ resume is available. Changed receipts can still cause verification and review
154
+ to repeat; no zero-cost promise is made. A succeeded run awaiting settlement
155
+ should use `runtime_settle`, preserving the existing implementation.
156
+
157
+ Paired Core now preserves OpenSpec's output when archive exits successfully
158
+ without creating its destination, and distinguishes that from multiple matching
159
+ destinations. Retrying a failed archive keeps valid verification/review receipts;
160
+ changed candidate files or environment still require fresh evidence. These Core
161
+ fixes require a newly bundled/released runtime; retained original runtimes are
162
+ not silently replaced or migrated. Diagnosis does not add arbitrary file-write
163
+ access: repairs without a supported scoped tool are reported as concrete manual
164
+ steps instead of being disguised as a reason to relaunch.
165
+
166
+ ## Repair the original worktree
167
+
168
+ `specrails_recovery` calls `POST /agent-runtime/runs/:runId/recovery` with a
169
+ strict action-specific request. It never constructs a new context/worktree.
170
+
171
+ | Action | Inputs and permission | Result |
172
+ | --- | --- | --- |
173
+ | inspect | read | Original repository IDs, registered check definitions, recent attempts |
174
+ | list_files / read_file / diff | repositoryId + relative path; read | Bounded directory/line/diff output; read_file includes SHA-256 |
175
+ | history | optional offset; read | Latest attempts first, pages of 20 with nextOffset |
176
+ | patch | repositoryId, path, expectedHash, oldText, newText, operationId UUID, reason; write | One unique replacement in an existing file, atomic publication |
177
+ | check | kind openspec or verification; saved checkId for verification; operationId + reason; destructive | Real validation evidence, bounded output and durable outcome |
178
+
179
+ Patches are limited to 16 KiB fragments and files readable within 128 KiB.
180
+ Runtime/provider metadata, secret paths, frozen OpenSpec changes/config/archives,
181
+ agent instructions, traversal, symlinks and platform aliases are rejected. New
182
+ files, deletions and arbitrary commands are not supported. Explicit repository IDs
183
+ prevent cross-project resolution. Verification runs one registered command with a
184
+ 45-second deadline; full acceptance remains the normal resume/settlement path.
185
+
186
+ Desktop blocks admission while project executions are active and reserves the
187
+ run/rail. Core takes the same cross-process lease as Resume. An interrupted write
188
+ requires `acknowledgeInterrupted` after inspecting partial changes. Completed or
189
+ archived runs cannot be patched or checked through recovery. Inspection remains
190
+ available. This is an application boundary, not an OS sandbox against unrelated
191
+ processes modifying the worktree concurrently.
192
+
193
+ Core stores at most 100 attempts in `recovery-history.json` beside the pipeline
194
+ state, separate from checkpoints. Each mutation has a write-ahead operation ID,
195
+ request fingerprint, cause, before/after hashes where relevant and final outcome.
196
+ Reuse the exact request/ID after transport uncertainty. A pending patch whose
197
+ after-hash is present is reconciled; other pending operations become interrupted
198
+ and are not rerun. Repeating a failed check on unchanged candidate code requires
199
+ a documented `changedPrecondition`; this records the operator's explanation, not
200
+ an independent proof that an external prerequisite changed.
201
+
202
+ Resume still enforces verification, review and acceptance. Scoped verification
203
+ uses Core's existing evidence/receipt store and cannot mark workflow phases done.
204
+ OpenSpec checks validate real files without copying or archiving them. Old retained
205
+ Core packages lacking `scopedRecovery` are not upgraded/migrated behind the run's
206
+ back: the tool returns a manual-repair limitation rather than recommending Relaunch.
@@ -5,7 +5,7 @@ architectural dependency declaration. Search a heading or filename and read only
5
5
  the relevant source and tests. Feature prefixes group the legacy server files;
6
6
  new bounded modules live under `server/modules/`.
7
7
 
8
- Includes 940 source/build files. Nearby tests are linked where names
8
+ Includes 942 source/build files. Nearby tests are linked where names
9
9
  match; integration suites may live elsewhere. Use `rg` to find other consumers.
10
10
 
11
11
  ## cli
@@ -979,6 +979,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
979
979
  - [plugins.ts](../../server/mcp/tools/plugins.ts) · [test](../../server/mcp/tools/plugins.test.ts)
980
980
  - [projects.ts](../../server/mcp/tools/projects.ts)
981
981
  - [rails.ts](../../server/mcp/tools/rails.ts)
982
+ - [recovery.ts](../../server/mcp/tools/recovery.ts) · [test](../../server/mcp/tools/recovery.test.ts)
982
983
  - [setup.ts](../../server/mcp/tools/setup.ts) · [test](../../server/mcp/tools/setup.test.ts)
983
984
  - [specs.ts](../../server/mcp/tools/specs.ts) · [test](../../server/mcp/tools/specs.test.ts)
984
985
  - [support.ts](../../server/mcp/tools/support.ts)
@@ -1031,6 +1032,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
1031
1032
  - [agent-runtime-metrics.ts](../../server/modules/agent-runtime/runtime/agent-runtime-metrics.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-metrics.test.ts)
1032
1033
  - [agent-runtime-package.ts](../../server/modules/agent-runtime/runtime/agent-runtime-package.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-package.test.ts)
1033
1034
  - [agent-runtime-paths.ts](../../server/modules/agent-runtime/runtime/agent-runtime-paths.ts)
1035
+ - [agent-runtime-recovery.ts](../../server/modules/agent-runtime/runtime/agent-runtime-recovery.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-recovery.test.ts)
1034
1036
  - [agent-runtime-repositories.ts](../../server/modules/agent-runtime/runtime/agent-runtime-repositories.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-repositories.test.ts)
1035
1037
  - [agent-runtime-settings-router.ts](../../server/modules/agent-runtime/runtime/agent-runtime-settings-router.ts)
1036
1038
  - [agent-runtime-settings.ts](../../server/modules/agent-runtime/runtime/agent-runtime-settings.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-settings.test.ts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.55.1",
3
+ "version": "2.56.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -342,16 +342,30 @@ can run Contract Refine require AI-spawn, including Explore conversions.
342
342
  trace or import is usually relative to a subdirectory), \`search\` (literal
343
343
  content search with line numbers and bounded snippets), \`read_file\`
344
344
  (bounded line ranges with continuation metadata),
345
- \`summary\`, \`provenance\`, \`diff\`. There is no MCP write path to files.
345
+ \`summary\`, \`provenance\`, \`diff\`. General Code Explorer remains read-only; stopped-run repairs use specrails_recovery.
346
346
  Start with content search to find behavior and tests, then read exact ranges.
347
347
  Truncated scans or skipped files do not prove that a symbol is absent.
348
- - **Run failures & recovery**: \`specrails_jobs(runtime_runs, jobId)\` reads
348
+ - **Run failures & recovery**: Start with \`specrails_jobs(runtime_diagnose, jobId)\`
349
+ for original scope, completed steps, repeated failures and receipt validity.
350
+ Availability of Resume is not a repair recommendation. After the same failure,
351
+ identify and validate a changed precondition before retrying; do not default
352
+ to Relaunch. Evidence can be paged with evidenceId/section/sourceId/cursor/limit.
353
+ \`specrails_jobs(runtime_runs, jobId)\` reads
349
354
  why a run stopped and what it offers (\`canResume\`, \`recoverableSteps\`,
350
355
  \`pendingApproval\`, \`pendingQuestion\`); \`runtime_evidence\` the durable
351
356
  evidence. Act only on the user's confirmation: \`runtime_resume\` /
352
357
  \`runtime_recover\` (ai-spawn), \`runtime_approve\` / \`runtime_settle\` /
353
358
  \`runtime_dismiss\` (write), \`runtime_cancel\` (destructive). In the in-app
354
359
  mission, a failed run also updates its card and posts a briefing turn.
360
+ - **Scoped repair**: \`specrails_recovery\` inspects/list_files/read_file/diff/history
361
+ in the original run scope. \`patch\` requires write permission, a current
362
+ expectedHash, a unique fragment, reason and operationId. \`check\` requires
363
+ destructive permission because registered verification executes project code;
364
+ select a saved checkId from inspect or kind openspec. No arbitrary commands,
365
+ frozen plan edits, checkpoint changes, deletes or automatic resume. Read the
366
+ durable outcome; reuse operationId on uncertain transport and explain a real
367
+ changedPrecondition before retrying a failed check. Old retained Core packages
368
+ may lack this capability; never silently migrate them.
355
369
  - **Execution evidence**: \`specrails_jobs(phase_breakdown)\` explains phases;
356
370
  job events are paginated. \`specrails_rails(pr_candidates)\` finds existing
357
371
  PR targets; \`review_packet(prDeliveryId)\` reads verification evidence.
@@ -8,6 +8,7 @@ const meta_1 = require("./meta");
8
8
  const specs_1 = require("./specs");
9
9
  const rails_1 = require("./rails");
10
10
  const jobs_1 = require("./jobs");
11
+ const recovery_1 = require("./recovery");
11
12
  const chat_1 = require("./chat");
12
13
  const agents_1 = require("./agents");
13
14
  const plugins_1 = require("./plugins");
@@ -38,6 +39,7 @@ function buildToolSpecs() {
38
39
  specs.push(...(0, specs_1.specsTools)());
39
40
  specs.push(...(0, rails_1.railsTools)());
40
41
  specs.push(...(0, jobs_1.jobsTools)());
42
+ specs.push(...(0, recovery_1.recoveryTools)());
41
43
  specs.push(...(0, chat_1.chatTools)());
42
44
  specs.push(...(0, agents_1.agentsTools)());
43
45
  specs.push(...(0, plugins_1.pluginsTools)());
@@ -39,7 +39,8 @@ function jobsTools() {
39
39
  'interactive_turn (ai-spawn — send a steering prompt to any running interactive job; claude jobs are interactive by default), ' +
40
40
  'finalize (settle a running interactive job now — Freestyle jobs otherwise wait for it, others auto-settle), ' +
41
41
  'runtime_runs (read the programmatic runtime state of jobId — or every run when omitted — status, nextStep, canResume, recoverableSteps, pendingApproval, pendingQuestion; THIS is how you learn why a run failed and what recovery it offers), ' +
42
- 'runtime_evidence (read the durable runtime evidence of jobId), ' +
42
+ 'runtime_diagnose (read-only recovery assessment for jobId: original scope, completed steps, repeated failures and verification invalidation reasons; use before recommending retry or relaunch), ' +
43
+ 'runtime_evidence (read the durable runtime evidence of jobId; page with evidenceId/section/sourceId/cursor/limit), ' +
43
44
  'runtime_resume (ai-spawn — continue a resumable run: optional approve/recover/invalidate step-id lists + answer for a pending question; act ONLY after the user confirmed on the card), ' +
44
45
  'runtime_recover (ai-spawn — shorthand for runtime_resume with recover:[stepIds] over the recoverable steps), ' +
45
46
  'runtime_approve (write — shorthand for runtime_resume with approve:[stepId] for a pending approval), ' +
@@ -86,6 +87,7 @@ function jobsTools() {
86
87
  'background_logs',
87
88
  'background_kill',
88
89
  'runtime_runs',
90
+ 'runtime_diagnose',
89
91
  'runtime_evidence',
90
92
  'runtime_resume',
91
93
  'runtime_recover',
@@ -102,7 +104,7 @@ function jobsTools() {
102
104
  .optional()
103
105
  .describe('Job id (for get / cancel / priority / diagnostic / interactive_turn / finalize)'),
104
106
  // ── list / export pagination + filters ──
105
- limit: zod_1.z.number().int().positive().max(200).optional().describe('Page size for list (1-200, default 50) / activity (1-100, default 50); background_logs returns the last N buffered log lines'),
107
+ limit: zod_1.z.number().int().positive().max(200).optional().describe('Page size for list (1-200, default 50) / activity (1-100, default 50) / runtime_evidence (1-100, default 25); background_logs returns the last N buffered log lines'),
106
108
  offset: zod_1.z.number().int().nonnegative().optional().describe('Page offset for list/background_list'),
107
109
  eventLimit: zod_1.z.number().int().positive().max(200).optional().describe('get: maximum persisted events to return (default 50, latest events by default)'),
108
110
  eventOffset: zod_1.z.number().int().nonnegative().optional().describe('get: chronological event offset; omit for the latest eventLimit events'),
@@ -151,6 +153,10 @@ function jobsTools() {
151
153
  invalidate: zod_1.z.array(zod_1.z.string()).optional().describe('runtime_resume: step ids whose results must be discarded before continuing'),
152
154
  answer: zod_1.z.string().optional().describe('runtime_resume: the answer to the run\'s pending question'),
153
155
  stepId: zod_1.z.string().optional().describe('runtime_approve/runtime_recover: the single step id (alternative to the arrays)'),
156
+ evidenceId: zod_1.z.string().max(1024).optional().describe('runtime_evidence: verification evidence id returned by the index'),
157
+ section: zod_1.z.enum(['summary', 'stdout', 'stderr', 'source']).optional().describe('runtime_evidence: summary index, command output or recorded source'),
158
+ sourceId: zod_1.z.string().max(1024).optional().describe('runtime_evidence: source id returned by the evidence index'),
159
+ cursor: zod_1.z.string().max(1024).optional().describe('runtime_evidence: continuation cursor returned by the previous page'),
154
160
  },
155
161
  async handler(ctx, args) {
156
162
  const base = (0, types_1.projectPath)(ctx, args.projectId);
@@ -350,11 +356,23 @@ function jobsTools() {
350
356
  const railIndex = args.railIndex;
351
357
  return (0, types_1.apiCall)(ctx, 'GET', `${base}/agent-runtime/runs${typeof railIndex === 'number' ? `?railIndex=${railIndex}` : ''}`);
352
358
  }
359
+ case 'runtime_diagnose': {
360
+ const id = args.jobId;
361
+ if (!id)
362
+ throw new Error('runtime_diagnose requires a "jobId".');
363
+ return (0, types_1.apiCall)(ctx, 'GET', `${base}/agent-runtime/runs/${encodeURIComponent(id)}/diagnosis`);
364
+ }
353
365
  case 'runtime_evidence': {
354
366
  const id = args.jobId;
355
367
  if (!id)
356
368
  throw new Error('runtime_evidence requires a "jobId".');
357
- return (0, types_1.apiCall)(ctx, 'GET', `${base}/agent-runtime/runs/${encodeURIComponent(id)}/evidence`);
369
+ const query = new URLSearchParams();
370
+ if (typeof args.evidenceId === 'string')
371
+ query.set('id', args.evidenceId);
372
+ for (const key of ['section', 'sourceId', 'cursor', 'limit'])
373
+ if (args[key] !== undefined)
374
+ query.set(key, String(args[key]));
375
+ return (0, types_1.apiCall)(ctx, 'GET', `${base}/agent-runtime/runs/${encodeURIComponent(id)}/evidence${query.size ? '?' + query.toString() : ''}`);
358
376
  }
359
377
  case 'runtime_resume':
360
378
  case 'runtime_recover':
@@ -371,7 +389,7 @@ function jobsTools() {
371
389
  recover = state.runs?.[0]?.recoverableSteps ?? [];
372
390
  }
373
391
  if (!recover.length)
374
- throw new Error('runtime_recover: the run has no recoverable steps — read runtime_runs and use runtime_resume instead.');
392
+ throw new Error('runtime_recover: the run has no interrupted steps to recover — read runtime_diagnose before deciding whether a repair or resume is appropriate.');
375
393
  body.recover = recover;
376
394
  }
377
395
  else if (action === 'runtime_approve') {
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.recoveryTools = recoveryTools;
4
+ const zod_1 = require("zod");
5
+ const agent_runtime_recovery_1 = require("../../modules/agent-runtime/runtime/agent-runtime-recovery");
6
+ const types_1 = require("./types");
7
+ function recoveryTools() {
8
+ return [{
9
+ name: 'specrails_recovery', title: 'Repair a stopped run', hintTier: 'read',
10
+ description: 'Inspect and repair the ORIGINAL saved run worktree without relaunching implementation. Actions: inspect (repositories, registered checks, recent repairs), list_files, read_file (paged lines + SHA-256), diff, history, patch (one exact unique replacement in an existing file, mandatory expectedHash and operationId), check (OpenSpec validation or ONE checkId returned by inspect). Patch requires write permission. Check executes project code and requires destructive permission. No arbitrary shell, checkpoint edits, frozen plan edits, file deletion or automatic resume. Reuse operationId after transport uncertainty; never blindly repeat failed checks. Read history and state a changedPrecondition before repeating a failed check on unchanged code. After successful repair/check use specrails_jobs runtime_resume under its normal authorization and verify the terminal outcome.',
11
+ tier: args => args.action === 'check' ? 'destructive' : args.action === 'patch' ? 'write' : 'read',
12
+ inputSchema: {
13
+ action: zod_1.z.enum(['inspect', 'list_files', 'read_file', 'diff', 'history', 'patch', 'check']),
14
+ projectId: zod_1.z.string().optional(), jobId: zod_1.z.string().min(1),
15
+ repositoryId: zod_1.z.string().optional().describe('Exact membership returned by inspect; required for file actions'),
16
+ path: zod_1.z.string().optional().describe('Repository-relative path; list_files accepts .'),
17
+ startLine: zod_1.z.number().int().positive().optional(), endLine: zod_1.z.number().int().positive().optional(),
18
+ offset: zod_1.z.number().int().nonnegative().optional().describe('history only: nextOffset from the previous page; newest attempts first'),
19
+ expectedHash: zod_1.z.string().optional().describe('Full-file hash returned by read_file, mandatory for patch'),
20
+ oldText: zod_1.z.string().optional(), newText: zod_1.z.string().optional(),
21
+ operationId: zod_1.z.string().uuid().optional().describe('New UUID per patch/check; reuse with the exact same request after uncertain transport'),
22
+ reason: zod_1.z.string().optional().describe('Specific diagnosed cause and purpose of the repair/check'),
23
+ kind: zod_1.z.enum(['openspec', 'verification']).optional(), checkId: zod_1.z.string().optional(),
24
+ changedPrecondition: zod_1.z.string().optional().describe('What changed since the failed check; required before repeating an unchanged candidate'),
25
+ acknowledgeInterrupted: zod_1.z.boolean().optional().describe('Only after inspecting and accepting the partial writes of an interrupted run'),
26
+ },
27
+ async handler(ctx, args) {
28
+ const { projectId, jobId, ...request } = args;
29
+ const body = agent_runtime_recovery_1.recoveryRequestSchema.parse(request);
30
+ if (typeof jobId !== 'string' || !jobId)
31
+ throw new Error('Scoped recovery requires jobId');
32
+ return (0, types_1.apiCall)(ctx, 'POST', `${(0, types_1.projectPath)(ctx, projectId)}/agent-runtime/runs/${encodeURIComponent(jobId)}/recovery`, body);
33
+ },
34
+ }];
35
+ }
@@ -244,7 +244,10 @@ function nextSteps(topic) {
244
244
  ];
245
245
  case 'rails_jobs':
246
246
  return [
247
- 'Use read tools for rails/jobs status before guessing: specrails_rails, specrails_jobs, and specrails_watch only when the user asks to wait.',
247
+ 'Read specrails_jobs runtime_diagnose for the failed job before recommending recovery; inspect runtime_evidence and original-worktree scope, not just the error label.',
248
+ 'If a step fails repeatedly, identify the prerequisite or artifact that must change and validate the smallest repair before retrying. canResume is not proof a retry will work. Relaunch is only justified by evidence that the original run cannot continue safely.',
249
+ 'Use specrails_recovery inspect/read_file/diff, then a hash-guarded patch and registered check for authorized small repairs in the original worktree. Read history and preserve operationId on uncertain transport.',
250
+ 'Preserve completed implementation. Use runtime_settle for a succeeded run awaiting delivery; name unsupported repair capabilities instead of proposing a fresh implementation.',
248
251
  'For a failed or stuck job, inspect the Job Detail logs and report the real failure.',
249
252
  'Do not relaunch or stop anything without confirmation and the required permission level.',
250
253
  ];
@@ -69,9 +69,13 @@ exports.PRICING = {
69
69
  // usage is the sole signal. Keys are the CLI's short aliases; full model ids
70
70
  // (e.g. per-event `message.model` like "claude-sonnet-4-6") are collapsed by
71
71
  // family via `resolvePriceEntry`. Cache write = 1.25x input (5-minute-TTL
72
- // default), cache read = 0.1x input. Anthropic usage semantics: input_tokens
72
+ // default); cache reads are model-specific (Opus 5.5 uses 0.05x input).
73
+ // Anthropic usage semantics: input_tokens
73
74
  // EXCLUDES cache reads/writes → inputIncludesCacheReads: false.
74
- 'claude:opus': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheWritePer1M: 6.25, inputIncludesCacheReads: false, lastReviewedAt: '2026-07-02' },
75
+ 'claude:claude-opus-5': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheWritePer1M: 6.25, inputIncludesCacheReads: false, lastReviewedAt: '2026-07-02' },
76
+ // Opus 5.5: https://platform.claude.com/docs/en/models/opus-5-5/overview
77
+ 'claude:opus': { inputPer1M: 4.00, outputPer1M: 20.00, cacheReadPer1M: 0.20, cacheWritePer1M: 5.00, inputIncludesCacheReads: false, lastReviewedAt: '2026-09-24' },
78
+ 'claude:claude-opus-5-5': { inputPer1M: 4.00, outputPer1M: 20.00, cacheReadPer1M: 0.20, cacheWritePer1M: 5.00, inputIncludesCacheReads: false, lastReviewedAt: '2026-09-24' },
75
79
  'claude:sonnet': { inputPer1M: 3.00, outputPer1M: 15.00, cacheReadPer1M: 0.30, cacheWritePer1M: 3.75, inputIncludesCacheReads: false, lastReviewedAt: '2026-07-02' },
76
80
  'claude:haiku': { inputPer1M: 1.00, outputPer1M: 5.00, cacheReadPer1M: 0.10, cacheWritePer1M: 1.25, inputIncludesCacheReads: false, lastReviewedAt: '2026-07-02' },
77
81
  'claude:fable': { inputPer1M: 10.00, outputPer1M: 50.00, cacheReadPer1M: 1.00, cacheWritePer1M: 12.50, inputIncludesCacheReads: false, lastReviewedAt: '2026-07-02' },
@@ -89,6 +93,9 @@ function resolvePriceEntry(providerId, model) {
89
93
  if (exact)
90
94
  return exact;
91
95
  if (providerId === 'claude') {
96
+ // Keep the existing estimates for older concrete Opus generations.
97
+ if (/^claude-opus-(?:4(?:-|$)|5$)/.test(model))
98
+ return exports.PRICING['claude:claude-opus-5'];
92
99
  const family = CLAUDE_MODEL_FAMILY.exec(model);
93
100
  if (family)
94
101
  return exports.PRICING[`claude:${family[1]}`];
@@ -83,6 +83,14 @@ function registerAgentRuntimeControlRoutes({ router, ctx }) {
83
83
  res.status(error instanceof agent_runtime_controls_1.RuntimeControlError ? error.statusCode : 503).json({ error: 'evidence_unavailable', message: error instanceof agent_runtime_controls_1.RuntimeControlError ? error.message : 'The original runtime evidence is unavailable' });
84
84
  }
85
85
  });
86
+ router.get('/:projectId/agent-runtime/runs/:runId/diagnosis', async (req, res) => {
87
+ try {
88
+ res.json(await controls(req).diagnose(String(req.params.runId)));
89
+ }
90
+ catch (error) {
91
+ res.status(error instanceof agent_runtime_controls_1.RuntimeControlError ? error.statusCode : 503).json({ error: 'diagnosis_unavailable', message: error instanceof agent_runtime_controls_1.RuntimeControlError ? error.message : 'Could not inspect the original runtime execution' });
92
+ }
93
+ });
86
94
  router.post('/:projectId/agent-runtime/runs/:runId/resume', async (req, res) => {
87
95
  try {
88
96
  await controls(req).resume(String(req.params.runId), (0, agent_runtime_controls_1.validateRuntimeResumeInput)(req.body));
@@ -92,6 +100,14 @@ function registerAgentRuntimeControlRoutes({ router, ctx }) {
92
100
  res.status(error instanceof agent_runtime_controls_1.RuntimeControlError ? error.statusCode : 500).json({ error: error instanceof agent_runtime_controls_1.RuntimeControlError ? error.code : 'runtime_resume_failed', message: error instanceof agent_runtime_controls_1.RuntimeControlError ? error.message : 'Could not resume runtime execution' });
93
101
  }
94
102
  });
103
+ router.post('/:projectId/agent-runtime/runs/:runId/recovery', async (req, res) => {
104
+ try {
105
+ res.json(await controls(req).recovery(String(req.params.runId), req.body));
106
+ }
107
+ catch (error) {
108
+ res.status(error instanceof agent_runtime_controls_1.RuntimeControlError ? error.statusCode : 500).json({ error: error instanceof agent_runtime_controls_1.RuntimeControlError ? error.code : 'runtime_recovery_failed', message: error instanceof agent_runtime_controls_1.RuntimeControlError ? error.message : 'Could not perform scoped recovery' });
109
+ }
110
+ });
95
111
  router.post('/:projectId/agent-runtime/runs/:runId/settle', async (req, res) => {
96
112
  try {
97
113
  await controls(req).settle(String(req.params.runId));
@@ -25,6 +25,7 @@ const loop_runs_store_1 = require("../../loops/runtime/loop-runs-store");
25
25
  const multi_repo_execution_store_1 = require("../../delivery/runtime/multi-repo-execution-store");
26
26
  const db_1 = require("../../../db");
27
27
  const loop_run_manager_1 = require("../../loops/runtime/loop-run-manager");
28
+ const agent_runtime_recovery_1 = require("./agent-runtime-recovery");
28
29
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
29
30
  const STEP_IDS = ['architect', 'developer', 'fixer', 'verify', 'reviewer', 'archive'];
30
31
  const ANSWER_LIMIT = 20_000;
@@ -94,7 +95,7 @@ async function readAgentRuntimeStatus(contextPath, cwd, env) {
94
95
  selection = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(node_path_1.default.dirname(contextPath), 'desktop-runtime-selection.json'), 'utf8'));
95
96
  }
96
97
  catch { /* Original hosts may not record selection origins. */ }
97
- return result.state ? { ...result.state, efficiencySummary: (0, agent_runtime_metrics_1.applyRuntimeSelectionOrigins)((0, agent_runtime_metrics_1.readRuntimeEfficiencySummary)(result.efficiencySummary), selection, result.state.runId) } : null;
98
+ return result.state ? { ...result.state, inspection: result.pipeline, efficiencySummary: (0, agent_runtime_metrics_1.applyRuntimeSelectionOrigins)((0, agent_runtime_metrics_1.readRuntimeEfficiencySummary)(result.efficiencySummary), selection, result.state.runId) } : null;
98
99
  }
99
100
  /** One controller per ProjectContext; Core's durable lease remains the final
100
101
  * cross-process guard. Resume never constructs a new context or worktree. */
@@ -165,6 +166,97 @@ class AgentRuntimeControls {
165
166
  throw new RuntimeControlError(503, 'evidence_unavailable', 'Core evidence is unavailable or incompatible');
166
167
  return result;
167
168
  }
169
+ async recovery(runId, input) {
170
+ const parsed = agent_runtime_recovery_1.recoveryRequestSchema.safeParse(input);
171
+ if (!parsed.success)
172
+ throw new RuntimeControlError(400, 'invalid_recovery_request', parsed.error.message);
173
+ if (this.disposed)
174
+ throw new RuntimeControlError(503, 'runtime_shutting_down', 'Project runtime is shutting down');
175
+ const parent = (0, loop_runs_store_1.getLoopRun)(this.ctx.db, runId);
176
+ const idle = () => parent?.status === 'completed' && !this.ctx.railLoopRuns?.size && !this.ctx.railJobs?.size
177
+ && !this.ctx.db.prepare("SELECT 1 FROM jobs WHERE status = 'running' LIMIT 1").get();
178
+ if (!idle() || this.active.size)
179
+ throw new RuntimeControlError(409, 'runtime_run_active', 'Wait for project executions to settle before scoped recovery');
180
+ const context = this.context(runId);
181
+ const active = { cancelled: false };
182
+ this.active.set(runId, active);
183
+ if (parent?.rail_index != null)
184
+ this.ctx.railLoopRuns?.set(runId, { railIndex: parent.rail_index, ticketIds: [], requiresTerminalIntent: true });
185
+ try {
186
+ const result = await (this.dependencies.recovery ?? agent_runtime_recovery_1.invokeRuntimeRecovery)({ contextPath: context.file, cwd: context.cwd, env: context.env, request: parsed.data,
187
+ onSpawn: child => { active.child = child; if (active.cancelled || this.disposed)
188
+ this.cancel(runId); },
189
+ });
190
+ if (parsed.data.action === 'patch' || parsed.data.action === 'check') {
191
+ this.statusCache.delete(runId);
192
+ this.ctx.db.transaction(() => {
193
+ const seq = this.ctx.db.prepare('SELECT COALESCE(MAX(seq), -1) + 1 AS seq FROM events WHERE job_id = ?').get(runId).seq;
194
+ (0, db_1.appendEvent)(this.ctx.db, runId, seq, { event_type: 'runtime-recovery', source: 'stdout', payload: JSON.stringify({ action: parsed.data.action, result }) });
195
+ })();
196
+ }
197
+ return result;
198
+ }
199
+ catch (error) {
200
+ if (error instanceof RuntimeControlError)
201
+ throw error;
202
+ throw new RuntimeControlError(409, 'runtime_recovery_blocked', error instanceof Error ? error.message : 'Recovery failed; inspect history before retrying');
203
+ }
204
+ finally {
205
+ clearTimeout(active.forceKillTimer);
206
+ this.active.delete(runId);
207
+ this.ctx.railLoopRuns?.delete(runId);
208
+ this.statusCache.delete(runId);
209
+ }
210
+ }
211
+ /** Read-only recovery assessment. canResume is admission, not a repair claim. */
212
+ async diagnose(runId) {
213
+ const summary = await this.summary(runId);
214
+ if (summary.historical || summary.status === 'unavailable')
215
+ return {
216
+ runId, summary, recommendation: 'inspect_saved_evidence',
217
+ reason: 'Live original runtime scope is unavailable. Preserve saved work and inspect the missing scope before proposing a fresh run.',
218
+ repeatFailureCount: null, recentFailures: [],
219
+ };
220
+ const { file, frozen, cwd, env } = this.context(runId);
221
+ const state = await this.dependencies.status(file, cwd, env);
222
+ if (!state || state.runId !== runId)
223
+ throw new RuntimeControlError(409, 'runtime_state_unavailable', 'No saved workflow state is available');
224
+ const failures = (state.recentFailures ?? []).slice(-8).map(item => ({ stepId: item.stepId, status: item.status, at: item.at, error: item.error?.slice(-6000) }));
225
+ const matching = failures.filter(item => item.status === 'failed' && item.stepId === state.nextStep && !!item.error && item.error === state.error?.slice(-6000));
226
+ const repeated = matching.length > 1;
227
+ const recommendation = summary.active ? 'wait_for_active_run'
228
+ : summary.canSettle ? 'prepare_delivery'
229
+ : summary.status === 'succeeded' ? 'inspect_delivery'
230
+ : summary.pendingQuestion ? 'answer_question'
231
+ : summary.pendingApproval ? 'inspect_and_approve'
232
+ : repeated ? 'repair_before_retry'
233
+ : summary.recoverableSteps.length ? 'inspect_interrupted_writes'
234
+ : 'diagnose_before_retry';
235
+ return {
236
+ runId, summary, recommendation,
237
+ reason: {
238
+ wait_for_active_run: 'The original run is still active. Inspect its progress instead of starting a competing execution.',
239
+ prepare_delivery: 'Core succeeded but Desktop settlement is still pending. Prepare delivery of the existing implementation.',
240
+ inspect_delivery: 'Core already succeeded. Inspect the delivery state rather than restarting implementation.',
241
+ answer_question: 'The run is waiting for the recorded question to be answered.',
242
+ inspect_and_approve: 'The run is waiting for approval of the recorded candidate; inspect its evidence first.',
243
+ repair_before_retry: 'The same step failed with the same error more than once. Identify and verify a changed precondition before spending on another attempt.',
244
+ inspect_interrupted_writes: 'An interrupted step may have partially changed files. Inspect the original worktree before explicitly recovering it.',
245
+ diagnose_before_retry: 'Determine the failing precondition from the saved evidence. A resumable run may still need a repair; a fresh run is not a diagnosis.',
246
+ }[recommendation],
247
+ originalScope: { artifactRoot: frozen.artifactRoot, repositories: frozen.repositories },
248
+ completedSteps: Object.entries(state.steps).filter(([, step]) => step.status === 'succeeded').map(([id]) => id),
249
+ recentFailures: failures, repeatFailureCount: state.recentFailures ? matching.length : null,
250
+ verification: state.inspection?.verification ? { valid: state.inspection.verification.valid, reasons: state.inspection.verification.reasons } : null,
251
+ acceptance: state.inspection?.acceptance ? { valid: state.inspection.acceptance.valid, reasons: state.inspection.acceptance.reasons } : null,
252
+ resumePhase: state.inspection?.resumePhase ?? null,
253
+ limitations: [
254
+ 'Recent failure history is bounded to eight attempts; null means the retained Core does not expose it.',
255
+ 'Completed steps are historical outcomes. Resume revalidates receipts and may repeat verification/review after changes; do not promise zero cost.',
256
+ 'This diagnostic does not execute repairs, resume, relaunch, publish or discard work.',
257
+ ],
258
+ };
259
+ }
168
260
  async list() {
169
261
  const directory = node_path_1.default.join((0, workspace_resolution_1.resolveProjectExecution)(this.ctx.project).specrailsDir, 'pipeline');
170
262
  if (!node_fs_1.default.existsSync(directory))
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.recoveryRequestSchema = void 0;
4
+ exports.invokeRuntimeRecovery = invokeRuntimeRecovery;
5
+ const node_child_process_1 = require("node:child_process");
6
+ const zod_1 = require("zod");
7
+ const agent_runtime_package_1 = require("./agent-runtime-package");
8
+ const core_node_runtime_1 = require("../../../core-node-runtime");
9
+ const win_spawn_1 = require("../../../util/win-spawn");
10
+ // Desktop validates its HTTP contract; retained Core independently validates
11
+ // the same operation at the filesystem/lease boundary.
12
+ const file = { repositoryId: zod_1.z.string().min(1).max(128), path: zod_1.z.string().min(1).max(1024) };
13
+ const operation = { operationId: zod_1.z.string().uuid(), reason: zod_1.z.string().trim().min(1).max(2000), acknowledgeInterrupted: zod_1.z.boolean().optional(), changedPrecondition: zod_1.z.string().trim().min(1).max(2000).optional() };
14
+ exports.recoveryRequestSchema = zod_1.z.discriminatedUnion('action', [
15
+ zod_1.z.object({ action: zod_1.z.literal('inspect') }).strict(),
16
+ zod_1.z.object({ action: zod_1.z.literal('history'), offset: zod_1.z.number().int().nonnegative().optional() }).strict(),
17
+ zod_1.z.object({ action: zod_1.z.literal('list_files'), ...file }).strict(),
18
+ zod_1.z.object({ action: zod_1.z.literal('read_file'), ...file, startLine: zod_1.z.number().int().positive().optional(), endLine: zod_1.z.number().int().positive().optional() }).strict(),
19
+ zod_1.z.object({ action: zod_1.z.literal('diff'), ...file }).strict(),
20
+ zod_1.z.object({ action: zod_1.z.literal('patch'), ...file, ...operation, expectedHash: zod_1.z.string().regex(/^[a-f0-9]{64}$/), oldText: zod_1.z.string().min(1).max(16_384), newText: zod_1.z.string().max(16_384) }).strict(),
21
+ zod_1.z.object({ action: zod_1.z.literal('check'), ...operation, kind: zod_1.z.enum(['openspec', 'verification']), checkId: zod_1.z.string().min(1).max(256).optional() }).strict(),
22
+ ]);
23
+ async function invokeRuntimeRecovery(input) {
24
+ const cli = (0, agent_runtime_package_1.resolveRetainedAgentRuntime)(input.contextPath);
25
+ const invoke = (args, stdin) => new Promise((resolve, reject) => {
26
+ const child = (0, node_child_process_1.execFile)((0, core_node_runtime_1.resolveCoreNodeRuntime)(), [cli, ...args], {
27
+ cwd: input.cwd, env: (0, win_spawn_1.windowsSpawnEnv)(input.env), windowsHide: true,
28
+ timeout: stdin ? 65_000 : 15_000, maxBuffer: 2 * 1024 * 1024, encoding: 'utf8',
29
+ }, (error, stdout) => {
30
+ if (!error) {
31
+ resolve(stdout);
32
+ return;
33
+ }
34
+ let detail = 'Recovery did not complete; inspect its history before retrying with the same operationId.';
35
+ try {
36
+ const parsed = JSON.parse(stdout);
37
+ if (typeof parsed.error === 'string')
38
+ detail = parsed.error.slice(-6000);
39
+ }
40
+ catch { /* Do not misrepresent an uncertain transport outcome. */ }
41
+ reject(new Error(detail));
42
+ });
43
+ input.onSpawn?.(child);
44
+ child.stdin?.end(stdin ?? '');
45
+ });
46
+ const api = JSON.parse(await invoke(['api']));
47
+ if (api.capabilities?.scopedRecovery !== 1)
48
+ throw new Error('The retained original Core does not support scoped recovery. Preserve this run; use a manual repair in its original worktree. Do not replace its runtime or relaunch to bypass this limit.');
49
+ const response = JSON.parse(await invoke(['recovery', '--context', input.contextPath, '--stdin'], JSON.stringify(input.request)));
50
+ if (response.type !== 'runtime-recovery' || response.schemaVersion !== 1 || !('result' in response))
51
+ throw new Error('Invalid Core recovery response; inspect recovery history before retrying');
52
+ return response.result;
53
+ }
@@ -48,7 +48,9 @@ function buildFailureBriefing(input) {
48
48
  const lines = [];
49
49
  lines.push('[Specrails run-failure briefing — automatic, not typed by the user]');
50
50
  lines.push('');
51
- lines.push(`${railLabel(input.railIndex, input.railName)} · run ${input.runId} stopped: ${describeFailureCode(input.failure.code)}.`);
51
+ const description = input.failure.code === 'implementation_failed' && input.failure.stepId
52
+ ? `the ${input.failure.stepId} step failed` : describeFailureCode(input.failure.code);
53
+ lines.push(`${railLabel(input.railIndex, input.railName)} · run ${input.runId} stopped: ${description}.`);
52
54
  lines.push(`Specs: ${specs.join(', ') || 'none'}.`);
53
55
  if (input.failure.stepId)
54
56
  lines.push(`Failed step: ${input.failure.stepId}.`);
@@ -81,7 +83,7 @@ function buildFailureBriefing(input) {
81
83
  if (input.prDeliveryId)
82
84
  lines.push(`Delivery id: ${input.prDeliveryId}.`);
83
85
  lines.push('');
84
- lines.push('Conduct: explain the failure in at most 6 short lines, name the ONE next action you recommend and why, and stop. Do NOT relaunch, resume or discard by yourself — the user decides on the card. If you need evidence, read it with specrails_jobs (get / runtime_runs / runtime_evidence) before answering.');
86
+ lines.push('Conduct: explain the failure in at most 6 short lines, name the ONE next action you recommend and why, and stop. Do NOT relaunch, resume or discard by yourself — the user decides on the card. Read specrails_jobs runtime_diagnose and, as needed, get / runtime_evidence before recommending recovery. canResume is not proof that retry fixes the cause. The next action may be diagnosis or a targeted repair, not a card button. If the same failure recurred, identify what must change before another attempt; do not default to Relaunch. This six-line limit applies only to this automatic turn.');
85
87
  return lines.join('\n');
86
88
  }
87
89
  /** The context ref persisted on the briefing row so the client renders it compactly. */