specrails-desktop 2.59.2 → 2.59.3

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 (87) hide show
  1. package/client/dist/assets/{ActivityFeedPage-vAY9C71W.js → ActivityFeedPage-BGEayjTY.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-CpwjGXQm.js → AgentBrowserCapture-Qvg4y5Tr.js} +1 -1
  3. package/client/dist/assets/{AgentModeAnalyticsPane-BHeoJVxQ.js → AgentModeAnalyticsPane-CR7aFeLJ.js} +2 -2
  4. package/client/dist/assets/{AgentModeCodePane-B0Nf3YkN.js → AgentModeCodePane-gAd88Gng.js} +2 -2
  5. package/client/dist/assets/{AgentModeJobsPane-CpbIItY_.js → AgentModeJobsPane-EjCDuTow.js} +1 -1
  6. package/client/dist/assets/{AgentsPage-BVJAFXn3.js → AgentsPage-CRHHcp1V.js} +1 -1
  7. package/client/dist/assets/{AnalyticsPage-DDWees-K.js → AnalyticsPage-a0b1fNEl.js} +1 -1
  8. package/client/dist/assets/{CodePage-CzV5tkvS.js → CodePage-BmV1m7l3.js} +1 -1
  9. package/client/dist/assets/{DesktopAnalyticsPage-DfRHNK7i.js → DesktopAnalyticsPage-B87WmXOQ.js} +1 -1
  10. package/client/dist/assets/{DocsDialog-BxduBYbh.js → DocsDialog-Co0O672q.js} +1 -1
  11. package/client/dist/assets/{DocsPage-DOVeRJn_.js → DocsPage-DxGI6A2T.js} +1 -1
  12. package/client/dist/assets/{ExportDropdown-BnKVV8UK.js → ExportDropdown-1Ce031vB.js} +1 -1
  13. package/client/dist/assets/{InteractiveJobComposer-Da9dvM-0.js → InteractiveJobComposer-DkqR77pO.js} +2 -2
  14. package/client/dist/assets/JobDetailModal-DVLDVgzz.js +1 -0
  15. package/client/dist/assets/JobDetailPage-RcP9N-D-.js +1 -0
  16. package/client/dist/assets/{JobsPage-0vdlbNik.js → JobsPage-R9M4xQnp.js} +1 -1
  17. package/client/dist/assets/{LoopBuilderPage-DCUHozXh.js → LoopBuilderPage-C4S-rkWp.js} +1 -1
  18. package/client/dist/assets/{LoopPreviewModal-Df-S8NGf.js → LoopPreviewModal-DA2JeKWX.js} +1 -1
  19. package/client/dist/assets/{LoopWindowSurface-sdb7QbSo.js → LoopWindowSurface-BkkeD0hx.js} +1 -1
  20. package/client/dist/assets/{LoopsPage-CDfCC9Px.js → LoopsPage-BHv--iI7.js} +1 -1
  21. package/client/dist/assets/{MinimizedChatsContext-DKlPf54Y.js → MinimizedChatsContext-Bc9xHByW.js} +1 -1
  22. package/client/dist/assets/PluginsPage-BEy62gxK.js +2 -0
  23. package/client/dist/assets/{ProjectSettingsDialog-DOkjT7xv.js → ProjectSettingsDialog-DDQ_f94T.js} +1 -1
  24. package/client/dist/assets/{RepositoryDeliveries-CJVpOUpZ.js → RepositoryDeliveries-DIWDFLRM.js} +1 -1
  25. package/client/dist/assets/{RepositoryScopeSelector-BeM-2gIZ.js → RepositoryScopeSelector-D3PlHIM-.js} +1 -1
  26. package/client/dist/assets/{ReviewPacketPage-EBCim2w_.js → ReviewPacketPage-BXdPwmdE.js} +1 -1
  27. package/client/dist/assets/{TemplatePreviewModal-1-92UGwN.js → TemplatePreviewModal-C7-G6lvI.js} +1 -1
  28. package/client/dist/assets/{TicketDetailModalContext-CkVXuHxU.js → TicketDetailModalContext-gGUv7gsQ.js} +1 -1
  29. package/client/dist/assets/{Trans-CzhPjnjB.js → Trans-Br_OIUKP.js} +1 -1
  30. package/client/dist/assets/{formatDistanceToNow-DlQaXc7c.js → formatDistanceToNow-Q7QPRFV4.js} +1 -1
  31. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-DxUFGcVq.js → getTimezoneOffsetInMilliseconds-Bq0CXBaJ.js} +1 -1
  32. package/client/dist/assets/{index-Czov72g7.js → index-DVkWHOR3.js} +7 -7
  33. package/client/dist/assets/jira-B6OYw4CJ.js +1 -0
  34. package/client/dist/assets/jira-BTF6RaOe.js +1 -0
  35. package/client/dist/assets/jira-CEetIB2q.js +1 -0
  36. package/client/dist/assets/jira-CIzsZLw5.js +1 -0
  37. package/client/dist/assets/{jira-BOoaUXbb.js → jira-Cq6yvN4q.js} +1 -1
  38. package/client/dist/assets/jira-Daw9tmpI.js +1 -0
  39. package/client/dist/assets/jira-FXr7u2lh.js +1 -0
  40. package/client/dist/assets/jira-LaNQn7yl.js +1 -0
  41. package/client/dist/assets/{jira-api-CbJvbRYx.js → jira-api-C2Tzc362.js} +1 -1
  42. package/client/dist/assets/{jobs-DZAbh1m2.js → jobs-ARwD85B9.js} +1 -1
  43. package/client/dist/assets/{jobs-BD47Ov11.js → jobs-BMF1w4WT.js} +1 -1
  44. package/client/dist/assets/{jobs-tWjcDF_6.js → jobs-CBfzGLwm.js} +1 -1
  45. package/client/dist/assets/{jobs-CSrRx5lD.js → jobs-CPjiluBY.js} +1 -1
  46. package/client/dist/assets/{jobs-epP7gt8W.js → jobs-CQQd58pY.js} +1 -1
  47. package/client/dist/assets/{jobs-1G3wodhl.js → jobs-De3EqaO0.js} +1 -1
  48. package/client/dist/assets/{jobs-BLOoVRRl.js → jobs-Du9-Ei9a.js} +1 -1
  49. package/client/dist/assets/{jobs-B4pu8TdF.js → jobs-qMHbnxwC.js} +1 -1
  50. package/client/dist/assets/{project-repositories-BkBD3rxx.js → project-repositories-DZKmieH3.js} +1 -1
  51. package/client/dist/assets/{spending-CYotH-9N.js → spending-EXaboRU0.js} +1 -1
  52. package/client/dist/assets/{useDesktop-DpxVGfUC.js → useDesktop-ChSmmJfc.js} +1 -1
  53. package/client/dist/assets/{useRuntimeRuns-CfGRbhr9.js → useRuntimeRuns-w9-5OzZl.js} +1 -1
  54. package/client/dist/assets/{useSharedWebSocket-C17TGqT8.js → useSharedWebSocket-g0nTHfTT.js} +2 -2
  55. package/client/dist/index.html +16 -16
  56. package/docs/internals/configuration.md +45 -0
  57. package/docs/internals/loop-step-log-explorer.md +27 -0
  58. package/docs/internals/mission-rail-cards.md +7 -0
  59. package/docs/internals/safe-pr-review-flow.md +9 -0
  60. package/docs/internals/source-map.md +5 -1
  61. package/docs/jira-integration-plan.md +7 -4
  62. package/package.json +1 -1
  63. package/server/dist/core-execution.js +5 -18
  64. package/server/dist/jira/jira-db.js +33 -6
  65. package/server/dist/jira/jira-materializer.js +11 -9
  66. package/server/dist/jira/jira-status-resolver.js +64 -22
  67. package/server/dist/jira/jira-sync-manager.js +40 -1
  68. package/server/dist/jira-router.js +8 -41
  69. package/server/dist/modules/agent-runtime/runtime/agent-runtime-bridge.js +15 -67
  70. package/server/dist/modules/agent-runtime/runtime/agent-runtime-controls.js +7 -4
  71. package/server/dist/modules/agent-runtime/runtime/agent-runtime-verification-log.js +252 -0
  72. package/server/dist/modules/delivery/runtime/rail-relaunch.js +10 -2
  73. package/server/dist/modules/loops/runtime/loop-run-manager.js +5 -3
  74. package/server/dist/path-resolver.js +41 -20
  75. package/server/dist/project-env.js +22 -6
  76. package/server/dist/util/checkout-path.js +22 -0
  77. package/server/dist/worktree-node-modules.js +70 -34
  78. package/client/dist/assets/JobDetailModal-DhwKbZGe.js +0 -1
  79. package/client/dist/assets/JobDetailPage-MtPE-E_1.js +0 -1
  80. package/client/dist/assets/PluginsPage-iL0G3rQQ.js +0 -2
  81. package/client/dist/assets/jira-BNZu8f9O.js +0 -1
  82. package/client/dist/assets/jira-BYVN4L2u.js +0 -1
  83. package/client/dist/assets/jira-CKJg3HZd.js +0 -1
  84. package/client/dist/assets/jira-DDDsZ4ub.js +0 -1
  85. package/client/dist/assets/jira-DXwYjVE4.js +0 -1
  86. package/client/dist/assets/jira-Dn6U2nRW.js +0 -1
  87. package/client/dist/assets/jira-j7hVolR5.js +0 -1
@@ -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-Czov72g7.js"></script>
207
+ <script type="module" crossorigin src="/assets/index-DVkWHOR3.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">
@@ -229,8 +229,8 @@
229
229
  <link rel="modulepreload" crossorigin href="/assets/dashboard-XGdQ5mjw.js">
230
230
  <link rel="modulepreload" crossorigin href="/assets/explore-DgBACn39.js">
231
231
  <link rel="modulepreload" crossorigin href="/assets/integrations-Dwl9KQp0.js">
232
- <link rel="modulepreload" crossorigin href="/assets/jira-CKJg3HZd.js">
233
- <link rel="modulepreload" crossorigin href="/assets/jobs-1G3wodhl.js">
232
+ <link rel="modulepreload" crossorigin href="/assets/jira-FXr7u2lh.js">
233
+ <link rel="modulepreload" crossorigin href="/assets/jobs-De3EqaO0.js">
234
234
  <link rel="modulepreload" crossorigin href="/assets/loops--KWr0X-w.js">
235
235
  <link rel="modulepreload" crossorigin href="/assets/mcp-CUAN6ORK.js">
236
236
  <link rel="modulepreload" crossorigin href="/assets/narration-n3hOA7LY.js">
@@ -243,15 +243,15 @@
243
243
  <link rel="modulepreload" crossorigin href="/assets/terminal-BAzpHtpY.js">
244
244
  <link rel="modulepreload" crossorigin href="/assets/tickets-D41ZMQpf.js">
245
245
  <link rel="modulepreload" crossorigin href="/assets/createLucideIcon-Bjbbp_KS.js">
246
- <link rel="modulepreload" crossorigin href="/assets/useSharedWebSocket-C17TGqT8.js">
247
- <link rel="modulepreload" crossorigin href="/assets/Trans-CzhPjnjB.js">
248
- <link rel="modulepreload" crossorigin href="/assets/useDesktop-DpxVGfUC.js">
246
+ <link rel="modulepreload" crossorigin href="/assets/useSharedWebSocket-g0nTHfTT.js">
247
+ <link rel="modulepreload" crossorigin href="/assets/Trans-Br_OIUKP.js">
248
+ <link rel="modulepreload" crossorigin href="/assets/useDesktop-ChSmmJfc.js">
249
249
  <link rel="modulepreload" crossorigin href="/assets/provider-capabilities-lePfeT_Y.js">
250
250
  <link rel="modulepreload" crossorigin href="/assets/save-OsUqVn99.js">
251
- <link rel="modulepreload" crossorigin href="/assets/spending-CYotH-9N.js">
251
+ <link rel="modulepreload" crossorigin href="/assets/spending-EXaboRU0.js">
252
252
  <link rel="modulepreload" crossorigin href="/assets/sparkles-DrY8hb0X.js">
253
253
  <link rel="modulepreload" crossorigin href="/assets/x-CBMI9ejn.js">
254
- <link rel="modulepreload" crossorigin href="/assets/MinimizedChatsContext-DKlPf54Y.js">
254
+ <link rel="modulepreload" crossorigin href="/assets/MinimizedChatsContext-Bc9xHByW.js">
255
255
  <link rel="modulepreload" crossorigin href="/assets/zap-CUOlvtT1.js">
256
256
  <link rel="modulepreload" crossorigin href="/assets/file-text-BcLCfcWZ.js">
257
257
  <link rel="modulepreload" crossorigin href="/assets/circle-x-CMYsN4ZH.js">
@@ -266,20 +266,20 @@
266
266
  <link rel="modulepreload" crossorigin href="/assets/search-Cq_gE3Ik.js">
267
267
  <link rel="modulepreload" crossorigin href="/assets/trash-2-BOU3WdZZ.js">
268
268
  <link rel="modulepreload" crossorigin href="/assets/triangle-alert-H9T4jAmh.js">
269
- <link rel="modulepreload" crossorigin href="/assets/jira-api-CbJvbRYx.js">
269
+ <link rel="modulepreload" crossorigin href="/assets/jira-api-C2Tzc362.js">
270
270
  <link rel="modulepreload" crossorigin href="/assets/clsx-CjueKrWZ.js">
271
271
  <link rel="modulepreload" crossorigin href="/assets/utils-BC4DHlz2.js">
272
272
  <link rel="modulepreload" crossorigin href="/assets/dialog-CMgps2MY.js">
273
273
  <link rel="modulepreload" crossorigin href="/assets/lib-CGdyxIWg.js">
274
- <link rel="modulepreload" crossorigin href="/assets/getTimezoneOffsetInMilliseconds-DxUFGcVq.js">
275
- <link rel="modulepreload" crossorigin href="/assets/formatDistanceToNow-DlQaXc7c.js">
274
+ <link rel="modulepreload" crossorigin href="/assets/getTimezoneOffsetInMilliseconds-Bq0CXBaJ.js">
275
+ <link rel="modulepreload" crossorigin href="/assets/formatDistanceToNow-Q7QPRFV4.js">
276
276
  <link rel="modulepreload" crossorigin href="/assets/button-D1xExSTF.js">
277
- <link rel="modulepreload" crossorigin href="/assets/project-repositories-BkBD3rxx.js">
278
- <link rel="modulepreload" crossorigin href="/assets/RepositoryScopeSelector-BeM-2gIZ.js">
279
- <link rel="modulepreload" crossorigin href="/assets/TicketDetailModalContext-CkVXuHxU.js">
277
+ <link rel="modulepreload" crossorigin href="/assets/project-repositories-DZKmieH3.js">
278
+ <link rel="modulepreload" crossorigin href="/assets/RepositoryScopeSelector-D3PlHIM-.js">
279
+ <link rel="modulepreload" crossorigin href="/assets/TicketDetailModalContext-gGUv7gsQ.js">
280
280
  <link rel="modulepreload" crossorigin href="/assets/core-B3mKBIh5.js">
281
- <link rel="modulepreload" crossorigin href="/assets/RepositoryDeliveries-CJVpOUpZ.js">
282
- <link rel="modulepreload" crossorigin href="/assets/useRuntimeRuns-CfGRbhr9.js">
281
+ <link rel="modulepreload" crossorigin href="/assets/RepositoryDeliveries-DIWDFLRM.js">
282
+ <link rel="modulepreload" crossorigin href="/assets/useRuntimeRuns-w9-5OzZl.js">
283
283
  <link rel="modulepreload" crossorigin href="/assets/event-Bt7f3wXe.js">
284
284
  <link rel="stylesheet" crossorigin href="/assets/index-Bfunvi6w.css">
285
285
  </head>
@@ -82,6 +82,31 @@ Project settings apply to a single project. Open them from the project's **Setti
82
82
 
83
83
  ---
84
84
 
85
+ ## Jira status mapping and pending updates
86
+
87
+ In the project's Jira connector, map **To Do**, **In Progress**, **On Review**,
88
+ **Done** and **Cancelled** to the real Jira statuses you want to use. Automatic
89
+ uses Jira's status categories; an explicit selection uses the selected status
90
+ identity, even when its category differs. The API accepts status names or status
91
+ IDs, never transition IDs. Both
92
+ outbound transitions and inbound polling honor the mapping. When several logical
93
+ states share a destination, On Review retains precedence; other ambiguities use
94
+ the category-derived state. Prefer distinct destinations when possible.
95
+
96
+ The pending-updates panel identifies the issue and requested destination. After
97
+ correcting a mapping or a Jira workflow restriction, retry the current failed
98
+ operation. A failed transition replaced by a later status intention is retained
99
+ as `superseded` history and removed from attention counts; retrying it cannot
100
+ revert newer work. Comments and field updates remain independently retryable.
101
+ Older pending retries are also suppressed when a newer status transition has
102
+ already completed. Normal pending operations retain their per-issue FIFO order.
103
+
104
+ Jira still determines which transitions the signed-in account can apply. A
105
+ missing reverse transition cannot be bypassed by selecting a target, and the
106
+ connector does not move through arbitrary statuses to discover possible paths.
107
+ If loading the status list fails, the connector preserves the saved mapping and
108
+ offers a retry instead of silently reducing the picker to Automatic.
109
+
85
110
  ## CLI
86
111
 
87
112
  The `specrails-desktop` CLI is documented in full in [../cli.md](../cli.md). Quick reference:
@@ -134,6 +159,26 @@ specrails-desktop --port 5000 implement "#42"
134
159
 
135
160
  ## Environment variables
136
161
 
162
+ ### Project execution environment
163
+
164
+ Project Settings stores the **names** of environment variables to pass through,
165
+ such as `NODE_AUTH_TOKEN`; it never stores their values. One parent project setting
166
+ applies to every repository and workspace admitted to its rails, including isolated
167
+ worktrees. The same policy is applied to retained runtime controls and recovery.
168
+
169
+ Non-empty values already inherited by Desktop take precedence. For missing configured names
170
+ on macOS/Linux, Desktop probes the login shell, using `SHELL` or the account's login
171
+ shell when a GUI launch does not supply it. A zsh login shell reads `~/.zprofile`
172
+ (and interactive configuration); configure the real credential there rather than
173
+ adding it to tracked repository files. Windows uses its inherited environment.
174
+
175
+ Recovered values stay in a project-owned in-memory overlay, not the server's global
176
+ environment, SQLite or runtime snapshots. Each project's lookup cache expires after
177
+ 30 seconds, including failed/empty results, so a repaired profile or rotated shell
178
+ credential can be picked up without restarting Desktop. Removing a configured name
179
+ drops its recovered value immediately. A missing value remains absent: Desktop never
180
+ substitutes a dummy credential or treats a blocked package-manager command as passed.
181
+
137
182
  A few env vars are read directly by the app. The two most useful operational ones:
138
183
 
139
184
  | Variable | Description |
@@ -147,6 +147,33 @@ One chip per graph node, states `pending → running → ok / failed / interrupt
147
147
  - **Filtering** searches every section: while the filter is non-empty, all sections are treated as expanded; clearing it restores the previous mode.
148
148
  - **Copy**: each step header carries its own copy button (per-step lines only; the control lives outside the toggle so the header stays a valid button), and the toolbar copy grabs the whole log with `── Step N · title ──` separators.
149
149
 
150
+ ### Verbose verification and retained display
151
+
152
+ Desktop compacts verification output before publishing readable log lines. Passing
153
+ cases and repeated console stacks no longer flood the rail; bounded diagnostic
154
+ context, final test totals and recorded command outcomes remain visible. Omission
155
+ notices distinguish a concise display from the original runtime evidence. This
156
+ changes presentation only, including when tests print warnings or errors but exit
157
+ successfully; recorded exit codes and workflow completion remain authoritative.
158
+
159
+ Both job detail surfaces omit invisible raw `verification-output` events from
160
+ their in-memory display buffer and preserve lifecycle markers when bounding
161
+ ordinary output. A translated notice indicates when earlier readable output was
162
+ removed and that Copy contains only the retained view. Initial history and live
163
+ updates use the same retention rules. Persisted events are unchanged.
164
+
165
+ For detailed verification output, expand **Details → Verification evidence** and
166
+ open a check's stdout or stderr, using the next-page control as needed. These are
167
+ the retained Core artifacts: currently up to 1 MiB per stream, with truncation
168
+ reported explicitly. They follow the configured runtime retention policy and are
169
+ not an unlimited archive. Compaction does not rewrite historical logs in storage.
170
+
171
+ Readable runtime lines preserve an optional recorded `attemptId` in both stored
172
+ log payloads and live WebSocket frames. The existing attempt map uses it to attach
173
+ buffered verification summaries to their originating step, including parallel
174
+ attempts. Older lines without that identity retain their existing arrival-order
175
+ fallback; Desktop does not invent an attempt ID.
176
+
150
177
  ## Performance
151
178
 
152
179
  - **Collapsed sections render nothing** — the body subtree is not mounted, so a 10k-line settled step costs one header row.
@@ -48,6 +48,13 @@ with allocation rollback and the original failed checkout retained. A synchronou
48
48
  click guard prevents duplicate submissions; accepted relaunches stay disabled
49
49
  until the new card appears, and rejected admission details remain inline.
50
50
 
51
+ Workspace narrowing is optional: when a delivery manifest has no explicit
52
+ workspace selections, Relaunch omits `workspaceSelection` and uses registered
53
+ defaults for its recorded repositories. Partial selections remain partial and
54
+ saved explicit selections take precedence; invalid selections still fail normal
55
+ admission. Removing the last repository with a workspace override from a launch
56
+ card also omits the property, instead of sending an invalid empty map.
57
+
51
58
  ```rail-launch
52
59
  {
53
60
  "version": 1,
@@ -124,6 +124,15 @@ Both temporary checkouts use non-force cleanup, including after a Git runner exc
124
124
  recording the failed replacement id so every client can safely undo its tombstone for that pair.
125
125
  - Isolation units remain `per-ticket` or one `all`-scope batch unit. Initial/final SHAs plus commit
126
126
  verification distinguish changed, resumed, and no-change results.
127
+ - Warm dependency preparation preserves a registered directory's position inside its Git checkout.
128
+ For example, registering `apps/catalog` prepares its installed packages and Yarn state beneath
129
+ `worktree/apps/catalog/node_modules`. Discovery stays relative to the registered directory
130
+ (depth at most two); unrelated root and sibling installations are not selected. Package entries
131
+ link to the source installation, while tool caches remain local. Existing real installations and
132
+ foreign links are preserved. Live cleanup uses the same projection and exact target fingerprints,
133
+ including proofs for historical misplaced links; symlinked destination ancestors confer no
134
+ authority. Registration paths, frozen manifests and Core scope remain unchanged. A failed run
135
+ keeps its old checkout; after updating Desktop, a fresh Relaunch receives the corrected layout.
127
136
  - Per-unit settlement returns structured execution + delivery results. `onLoopRunFinished` receives
128
137
  the engine outcome only; commit/status/ref/provenance/push failures cannot rewrite it.
129
138
  - Before staging, settlement blocks active OpenSpec change directories introduced relative to
@@ -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 1014 source/build files. Nearby tests are linked where names
8
+ Includes 1018 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
@@ -317,6 +317,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
317
317
  - [DiscardSpecDialog.tsx](../../client/src/features/integrations/components/jira/DiscardSpecDialog.tsx) · [test](../../client/src/features/integrations/components/jira/__tests__/DiscardSpecDialog.test.tsx)
318
318
  - [JiraConnectWizard.tsx](../../client/src/features/integrations/components/jira/JiraConnectWizard.tsx) · [test](../../client/src/features/integrations/components/jira/__tests__/JiraConnectWizard.test.tsx)
319
319
  - [JiraConnectedCard.tsx](../../client/src/features/integrations/components/jira/JiraConnectedCard.tsx)
320
+ - [JiraOutboxPanel.tsx](../../client/src/features/integrations/components/jira/JiraOutboxPanel.tsx) · [test](../../client/src/features/integrations/components/jira/__tests__/JiraOutboxPanel.test.tsx)
320
321
  - [JiraSpecDetailsPanel.tsx](../../client/src/features/integrations/components/jira/JiraSpecDetailsPanel.tsx) · [test](../../client/src/features/integrations/components/jira/__tests__/JiraSpecDetailsPanel.test.tsx)
321
322
 
322
323
  ## client/src/features/integrations/context
@@ -365,6 +366,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
365
366
  ## client/src/features/jobs/lib
366
367
 
367
368
  - [cancel-job.ts](../../client/src/features/jobs/lib/cancel-job.ts) · [test](../../client/src/features/jobs/lib/__tests__/cancel-job.test.ts)
369
+ - [job-event-buffer.ts](../../client/src/features/jobs/lib/job-event-buffer.ts) · [test](../../client/src/features/jobs/lib/__tests__/job-event-buffer.test.ts)
368
370
  - [job-log-mode.ts](../../client/src/features/jobs/lib/job-log-mode.ts)
369
371
  - [job-time.ts](../../client/src/features/jobs/lib/job-time.ts)
370
372
 
@@ -1093,6 +1095,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
1093
1095
  - [agent-runtime-settings-router.ts](../../server/modules/agent-runtime/runtime/agent-runtime-settings-router.ts)
1094
1096
  - [agent-runtime-settings.ts](../../server/modules/agent-runtime/runtime/agent-runtime-settings.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-settings.test.ts)
1095
1097
  - [agent-runtime-settlement.ts](../../server/modules/agent-runtime/runtime/agent-runtime-settlement.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-settlement.test.ts)
1098
+ - [agent-runtime-verification-log.ts](../../server/modules/agent-runtime/runtime/agent-runtime-verification-log.ts)
1096
1099
  - [agent-runtime-verification-suggestions.ts](../../server/modules/agent-runtime/runtime/agent-runtime-verification-suggestions.ts) · [test](../../server/modules/agent-runtime/runtime/agent-runtime-verification-suggestions.test.ts)
1097
1100
 
1098
1101
  ## server/modules/agents/runtime
@@ -1424,6 +1427,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
1424
1427
 
1425
1428
  ## server/util
1426
1429
 
1430
+ - [checkout-path.ts](../../server/util/checkout-path.ts)
1427
1431
  - [cli-prompt.ts](../../server/util/cli-prompt.ts) · [test](../../server/util/cli-prompt.test.ts)
1428
1432
  - [distribute-int.ts](../../server/util/distribute-int.ts)
1429
1433
  - [secure-fs.ts](../../server/util/secure-fs.ts) · [test](../../server/util/secure-fs.test.ts)
@@ -190,16 +190,18 @@ Por qué token‑paste y no OAuth en v1:
190
190
 
191
191
  ## 6. Ciclo de vida y mapeo de estados (la parte difícil)
192
192
 
193
- Los issues de Jira **no tienen un campo `status` asignable** — hay que **transicionar**, y las transiciones están **gateadas por el workflow** del cliente (que es arbitrario). Las 4 lógicas de Specrails (`todo/in_progress/done/cancelled`) deben caer sobre N estados de cliente repartidos en solo **3 categorías estables**: `new` / `indeterminate` / `done` (`statusCategory.key`).
193
+ Los issues de Jira **no tienen un campo `status` asignable** — hay que **transicionar**, y las transiciones están **gateadas por el workflow** del cliente (que es arbitrario). Las 5 lógicas de Specrails (`todo/in_progress/on_review/done/cancelled`) deben caer sobre N estados de cliente repartidos en solo **3 categorías estables**: `new` / `indeterminate` / `done` (`statusCategory.key`).
194
194
 
195
195
  ### Resolver de dos niveles
196
196
 
197
197
  1. **Mapa explícito por proyecto gana siempre.** En Settings el usuario elige, de la **lista real de estados** de su proyecto (fetched en vivo), el target para cada estado lógico. Esto resuelve la ambigüedad (p.ej. dos estados sobre categoría `done`: `Released` vs `Won't Do`).
198
198
  2. **Fallback por categoría** cuando no hay mapa: anclar en `statusCategory.key` (nunca en el **nombre** localizable del estado). Para `cancelled`, preferir un *cancel‑lexicon* (`won't do`, `cancelled`, `rejected`, `abandoned`, `invalid`, `duplicate`) y fijar `resolution`; para `done`/éxito, preferir un *ship‑lexicon* (`done`, `closed`, `released`, `resolved`, `complete`) y **alejarse** del cancel‑lexicon.
199
199
 
200
- ### Camino de transición — BFS por saltos
200
+ El mapa también se respeta al materializar Jira en la caché local para los cinco estados lógicos. Si varios estados comparten destino, `on_review` mantiene su prioridad histórica; para otras ambigüedades se conserva la interpretación por categoría.
201
201
 
202
- Como solo ves las transiciones salientes del estado **actual**, un workflow `Backlog → Selected for Dev → In Progress → Done` no ofrece arista directa a `Done`. Algoritmo: `GET /transitions` del issue vivo → aplicar la arista que reduce la distancia a la categoría objetivo (orden `new < indeterminate < done`) → re‑`GET` → repetir. Cap ~5 saltos, dedup de estados visitados (evitar bucles), parar si ninguna transición reduce distancia. **Idempotency‑first**: si la categoría actual ya es la objetivo, **no‑op**. Si no hay camino en N saltos → **dead‑letter** no‑fatal ("mover estado manualmente en Jira") y **jamás** se hace error del rail.
202
+ ### Camino de transición — recorrido acotado por categorías
203
+
204
+ Como solo ves las transiciones salientes del estado **actual**, un workflow `Backlog → Selected for Dev → In Progress → Done` no ofrece arista directa a `Done`. Algoritmo: `GET /transitions` del issue vivo → aplicar la arista que reduce la distancia a la categoría objetivo (orden `new < indeterminate < done`) → re‑`GET` → repetir. Cap ~5 saltos, dedup de estados visitados (evitar bucles), parar si ninguna transición reduce distancia. **Idempotency‑first**: si hay destino explícito, comparar primero su ID/nombre con el estado actual, incluso si pertenece a otra categoría. Sin destino explícito, la categoría objetivo permite el **no‑op**. Un estado intermedio de la categoría esperada no completa un destino explícito diferente. Este recorrido no explora estados laterales modificando Jira a ciegas. Si no hay camino en N saltos → **dead‑letter** no‑fatal ("mover estado manualmente en Jira") y **jamás** se hace error del rail.
203
205
 
204
206
  ### Pantallas de transición / campos requeridos
205
207
 
@@ -238,10 +240,11 @@ Diseño de poll:
238
240
  ### Outbound — outbox durable
239
241
 
240
242
  - Drenaje en worker de fondo: **FIFO por‑issue** (una transición debe aterrizar antes que el comentario que la describe), **paralelo entre issues distintos**, con cap de concurrencia.
241
- - **Idempotencia:** transiciones por no‑op‑si‑ya‑en‑categoría; **comentarios** con un *self‑marker* invisible embebido en el body ADF (`[specrails:job-<id>]`) — Jira no tiene idempotencia nativa de comentarios, así que antes de re‑postear se hace `GET .../comment` y se salta si el marker ya existe. El marker dobla como filtro de auto‑eco en el poll.
243
+ - **Idempotencia:** transiciones por identidad explícita ID/nombre y, sin mapa, por categoría; **comentarios** con un *self‑marker* invisible embebido en el body ADF (`[specrails:job-<id>]`) — Jira no tiene idempotencia nativa de comentarios, así que antes de re‑postear se hace `GET .../comment` y se salta si el marker ya existe. El marker dobla como filtro de auto‑eco en el poll.
242
244
  - **Rate limits:** honrar `Retry-After` en 429 exacto; si ausente, backoff exponencial con jitter (base 2s, cap 30s, ~4 reintentos); respetar el techo ~20 writes/2s por issue. Token‑bucket por debajo de los burst caps.
243
245
  - **Clasificación de errores:** `401` = credencial → **pausar** outbox del proyecto + banner re‑auth (no reintentar en bucle); `403` = permiso de operación concreta → dead‑letter nombrando la operación ("tu cuenta no puede transicionar en PROJ"), sin inferir fallo global; `404` sobre issue conocido = terminal (issue borrado/movido) → marcar link `orphaned`, parar la op; solo `429/5xx/timeout` son reintentables.
244
246
  - **Dead‑letter visible** con reintento manual: `GET /jira/outbox`, `POST /jira/outbox/:id/retry`, indicador `JiraSyncIndicator` en UI. Un workflow‑gap o un 403 **nunca** es un drop silencioso.
247
+ - **Operaciones reemplazadas:** las transiciones fallidas con una intención posterior para el mismo issue se conservan como `superseded`, fuera del contador de atención y sin reintento. Un reintento antiguo ya encolado tampoco se aplica si una transición posterior ya terminó. Se conservan el FIFO normal y los comentarios/ediciones independientes. El panel identifica el issue y el destino, y muestra errores al cargar los estados con una acción de reintento.
245
248
 
246
249
  ---
247
250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.59.2",
3
+ "version": "2.59.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.checkoutSubdirectory = checkoutSubdirectory;
3
+ exports.checkoutSubdirectory = void 0;
4
4
  exports.prepareCoreExecution = prepareCoreExecution;
5
5
  exports.coreVerificationContext = coreVerificationContext;
6
6
  exports.checkCoreCompletion = checkCoreCompletion;
@@ -10,6 +10,9 @@ const node_path_1 = require("node:path");
10
10
  const core_node_runtime_1 = require("./core-node-runtime");
11
11
  const core_update_state_1 = require("./core-update-state");
12
12
  const agent_runtime_loader_1 = require("./modules/agent-runtime/runtime/agent-runtime-loader");
13
+ const checkout_path_1 = require("./util/checkout-path");
14
+ var checkout_path_2 = require("./util/checkout-path");
15
+ Object.defineProperty(exports, "checkoutSubdirectory", { enumerable: true, get: function () { return checkout_path_2.checkoutSubdirectory; } });
13
16
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
14
17
  // Resolve existing ancestors too: the tickets file may not exist yet, and macOS
15
18
  // exposes temporary directories through both /var and /private/var.
@@ -26,22 +29,6 @@ function canonicalPath(path) {
26
29
  return (0, node_path_1.join)(canonicalPath(parent), (0, node_path_1.basename)(path));
27
30
  }
28
31
  }
29
- /** Where a directory sits inside its git checkout: '' for the checkout root or a directory outside git. */
30
- function checkoutSubdirectory(directory) {
31
- let start;
32
- try {
33
- start = (0, node_fs_1.realpathSync)(directory);
34
- }
35
- catch {
36
- return '';
37
- }
38
- for (let current = start;; current = (0, node_path_1.dirname)(current)) {
39
- if ((0, node_fs_1.existsSync)((0, node_path_1.join)(current, '.git')))
40
- return (0, node_path_1.relative)(current, start).split(node_path_1.sep).join('/');
41
- if ((0, node_path_1.dirname)(current) === current)
42
- return '';
43
- }
44
- }
45
32
  /**
46
33
  * A registered repository may be one package of a larger git checkout (a
47
34
  * monorepo app). An isolated worktree always mounts the whole checkout, so the
@@ -54,7 +41,7 @@ function registeredScope(checkout, sourcePath) {
54
41
  (0, node_fs_1.realpathSync)(sourcePath);
55
42
  const local = (0, node_path_1.relative)(checkout, sourcePath).split(node_path_1.sep).join('/');
56
43
  const contained = local && local !== '..' && !local.startsWith('../') && !(0, node_path_1.isAbsolute)(local);
57
- const prefix = checkoutSubdirectory(checkout) ? (contained ? local : '') : checkoutSubdirectory(sourcePath) || (contained ? local : '');
44
+ const prefix = (0, checkout_path_1.checkoutSubdirectory)(checkout) ? (contained ? local : '') : (0, checkout_path_1.checkoutSubdirectory)(sourcePath) || (contained ? local : '');
58
45
  if (!prefix)
59
46
  return undefined;
60
47
  if (!(0, node_fs_1.statSync)((0, node_path_1.join)(checkout, ...prefix.split('/'))).isDirectory())
@@ -33,6 +33,7 @@ exports.markOutboxDone = markOutboxDone;
33
33
  exports.markOutboxRetry = markOutboxRetry;
34
34
  exports.markOutboxDead = markOutboxDead;
35
35
  exports.resetInflight = resetInflight;
36
+ exports.reconcileSupersededTransitions = reconcileSupersededTransitions;
36
37
  exports.retryDeadOutbox = retryDeadOutbox;
37
38
  exports.listOutbox = listOutbox;
38
39
  exports.countOutboxByState = countOutboxByState;
@@ -328,12 +329,38 @@ function resetInflight(db) {
328
329
  .run(new Date().toISOString());
329
330
  return info.changes;
330
331
  }
331
- /** Re-queue a dead-lettered op for a manual retry. */
332
+ /**
333
+ * Preserve obsolete failed status intentions as terminal history rather than
334
+ * offering a retry that could revert a newer intention. Ordinary pending and
335
+ * inflight operations retain FIFO ownership. A pending row with a newer DONE
336
+ * transition is an old retry (normal FIFO cannot produce that ordering).
337
+ * Comments and field edits are independent operations and never superseded.
338
+ */
339
+ function reconcileSupersededTransitions(db) {
340
+ return db.prepare(`UPDATE jira_outbox AS candidate
341
+ SET state = 'superseded', next_attempt_at = NULL, updated_at = ?
342
+ WHERE candidate.op_type = 'transition'
343
+ AND candidate.state IN ('dead', 'pending')
344
+ AND EXISTS (
345
+ SELECT 1 FROM jira_outbox AS newer
346
+ WHERE newer.jira_issue_id = candidate.jira_issue_id
347
+ AND newer.op_type = 'transition'
348
+ AND newer.id > candidate.id
349
+ AND (candidate.state = 'dead' OR newer.state = 'done')
350
+ )`).run(new Date().toISOString()).changes;
351
+ }
352
+ /** Reconcile and re-queue atomically, so an obsolete intention is never revived. */
332
353
  function retryDeadOutbox(db, id) {
333
- const info = db
334
- .prepare("UPDATE jira_outbox SET state = 'pending', next_attempt_at = NULL, dead_reason = NULL, last_error = NULL, updated_at = ? WHERE id = ? AND state = 'dead'")
335
- .run(new Date().toISOString(), id);
336
- return info.changes > 0;
354
+ return db.transaction(() => {
355
+ reconcileSupersededTransitions(db);
356
+ const row = db.prepare('SELECT state FROM jira_outbox WHERE id = ?').get(id);
357
+ if (row?.state === 'superseded')
358
+ return 'superseded';
359
+ if (row?.state !== 'dead')
360
+ return null;
361
+ db.prepare("UPDATE jira_outbox SET state = 'pending', next_attempt_at = NULL, dead_reason = NULL, last_error = NULL, updated_at = ? WHERE id = ? AND state = 'dead'").run(new Date().toISOString(), id);
362
+ return 'pending';
363
+ })();
337
364
  }
338
365
  function listOutbox(db, opts = {}) {
339
366
  const limit = Math.min(opts.limit ?? 200, 1000);
@@ -344,7 +371,7 @@ function listOutbox(db, opts = {}) {
344
371
  }
345
372
  function countOutboxByState(db) {
346
373
  const rows = db.prepare('SELECT state, COUNT(*) AS n FROM jira_outbox GROUP BY state').all();
347
- const out = { pending: 0, inflight: 0, done: 0, dead: 0 };
374
+ const out = { pending: 0, inflight: 0, done: 0, dead: 0, superseded: 0 };
348
375
  for (const r of rows) {
349
376
  if (r.state in out)
350
377
  out[r.state] = r.n;
@@ -37,19 +37,21 @@ function issueStatusCategory(issue) {
37
37
  return k === 'new' || k === 'done' ? k : 'indeterminate';
38
38
  }
39
39
  /**
40
- * Map a Jira issue's status to a Specrails ticket status. When the connection
41
- * has an explicit `statusMap.on_review` and the issue's status NAME equals it
42
- * (case-insensitive), the inbound status is preserved as `on_review` — the
43
- * category fallback alone would flatten it to `in_progress` and silently revert
44
- * a ticket parked at on_review once its outbox transition drains (the frozen
45
- * guard only protects the pending window). Unconfigured ⇒ byte-identical
46
- * category mapping.
40
+ * Explicit status identities apply in both sync directions, even when a
41
+ * customer's category differs from the logical state's default. Preserve the
42
+ * historical on_review precedence for shared mappings; other ambiguous targets
43
+ * retain category fallback rather than choosing by configuration object order.
47
44
  */
48
45
  function mapStatus(issue, statusMap) {
49
46
  const name = (issue.fields.status?.name ?? '').toLowerCase();
50
- const onReviewTarget = statusMap?.on_review;
51
- if (onReviewTarget && name && name === onReviewTarget.toLowerCase())
47
+ const matches = (target) => Boolean(target
48
+ && (issue.fields.status?.id === target || (name && name === target.toLowerCase())));
49
+ if (matches(statusMap?.on_review))
52
50
  return 'on_review';
51
+ const states = ['todo', 'in_progress', 'done', 'cancelled'];
52
+ const mapped = states.filter((state) => matches(statusMap?.[state]));
53
+ if (mapped.length === 1)
54
+ return mapped[0];
53
55
  const cat = issueStatusCategory(issue);
54
56
  if (cat === 'new')
55
57
  return 'todo';
@@ -9,8 +9,8 @@
9
9
  // 2. Category fallback anchored on `statusCategory.key` (never the localizable
10
10
  // status NAME), with a cancel/ship lexicon to disambiguate the `done`
11
11
  // category (e.g. `Won't Do` vs `Released`).
12
- // 3. BFS transition walk for forward-only workflows where there is no direct
13
- // edge to the target category.
12
+ // 3. Bounded category-monotonic transition walk where there is no direct edge
13
+ // to the target category. Unknown lateral paths are never explored by writes.
14
14
  //
15
15
  // This module is PURE: the walker takes async callbacks (getTransitions /
16
16
  // applyTransition) so it is fully testable without HTTP.
@@ -60,12 +60,14 @@ function nameMatches(name, lexicon) {
60
60
  function isCancelName(name) {
61
61
  return nameMatches(name, CANCEL_LEXICON);
62
62
  }
63
+ function matchesStatusTarget(id, name, target) {
64
+ return id === target || (name !== undefined && name.toLowerCase() === target.toLowerCase());
65
+ }
63
66
  /** Does this transition's destination match the user's explicitly configured
64
- * target (by status id, status name, or transition id)? Case-insensitive on name.
67
+ * target (by status id or status name)? Case-insensitive on name.
65
68
  * The explicit config ALWAYS wins over the cancel/ship lexicon heuristics. */
66
69
  function matchesExplicit(t, explicitTarget) {
67
- const e = explicitTarget.toLowerCase();
68
- return t.to.id === explicitTarget || t.to.name.toLowerCase() === e || t.id === explicitTarget;
70
+ return matchesStatusTarget(t.to.id, t.to.name, explicitTarget);
69
71
  }
70
72
  function transitionCategory(t) {
71
73
  const k = t.to.statusCategory?.key;
@@ -81,6 +83,8 @@ function transitionCategory(t) {
81
83
  */
82
84
  function pickDirectTransition(transitions, state, explicitTarget) {
83
85
  if (explicitTarget) {
86
+ // Values identify destination statuses, never transition IDs: the two ID
87
+ // namespaces can collide, including with an unrelated terminal transition.
84
88
  const t = transitions.find((tr) => matchesExplicit(tr, explicitTarget));
85
89
  if (t)
86
90
  return t;
@@ -117,9 +121,9 @@ function pickDirectTransition(transitions, state, explicitTarget) {
117
121
  }
118
122
  /**
119
123
  * Pick a transition that moves the issue closer to the target category (used by
120
- * the BFS walk when no direct transition exists). Returns the edge whose target
124
+ * the bounded walk when no direct transition exists). Returns the edge whose target
121
125
  * category is strictly closer (in rank distance) to the goal, never overshooting
122
- * past it, preferring the smallest forward step.
126
+ * past it, preferring the furthest progress in either direction.
123
127
  */
124
128
  function pickProgressTransition(transitions, currentCategory, targetCategory, visitedStatusIds, opts) {
125
129
  const goal = categoryRank(targetCategory);
@@ -189,26 +193,57 @@ function buildTransitionFields(transition, state) {
189
193
  }
190
194
  return Object.keys(out).length > 0 ? { fields: out } : {};
191
195
  }
196
+ function diagnosticValue(value, limit = 64) {
197
+ const compact = value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
198
+ if (compact.length <= limit && JSON.stringify(compact).length <= limit)
199
+ return JSON.stringify(compact);
200
+ let preview = compact.slice(0, limit);
201
+ while (JSON.stringify(`${preview}…`).length > limit)
202
+ preview = preview.slice(0, -1);
203
+ return JSON.stringify(`${preview}…`);
204
+ }
205
+ function statusDiagnostic(name, id) {
206
+ return `${name ? diagnosticValue(name, 48) : '(unknown)'}${id ? ` (id ${diagnosticValue(id, 24)})` : ''}`;
207
+ }
208
+ function noPathReason(summary, currentName, currentId, explicitTarget, transitions) {
209
+ // Dead-letter storage retains at most 500 characters. Budget the entire
210
+ // message here so available destinations are never silently cut off there.
211
+ const prefix = `${summary.slice(0, 160)}; current status ${statusDiagnostic(currentName, currentId)}`
212
+ + (explicitTarget ? `; configured target ${diagnosticValue(explicitTarget)}` : '')
213
+ + '; available destinations: ';
214
+ if (transitions === undefined)
215
+ return `${prefix}not fetched for current status`;
216
+ const available = [];
217
+ for (const transition of transitions.slice(0, 8)) {
218
+ const candidate = [...available, statusDiagnostic(transition.to.name, transition.to.id)];
219
+ const omitted = transitions.length - candidate.length;
220
+ const suffix = omitted > 0 ? `; +${omitted} more` : '';
221
+ if (`${prefix}${candidate.join(', ')}${suffix}`.length > 500)
222
+ break;
223
+ available.push(candidate[candidate.length - 1]);
224
+ }
225
+ const omitted = transitions.length - available.length;
226
+ return `${prefix}${available.join(', ') || (omitted ? '(omitted)' : 'none')}${omitted > 0 ? `; +${omitted} more` : ''}`;
227
+ }
192
228
  /**
193
229
  * Walk the transition graph from the current category to the target category for
194
230
  * `state`, applying edges per hop (you can only see the current status's
195
- * outgoing edges). Idempotency-first: if already in the target category, no-op —
196
- * UNLESS an explicit target status is configured and the issue is not already
197
- * sitting on it (see `currentStatusName`).
231
+ * outgoing edges). An explicit destination's identity wins over the default
232
+ * category, including idempotency. Without a destination, the category suffices.
198
233
  */
199
234
  async function walkToCategory(args) {
200
235
  const target = targetCategoryFor(args.state);
201
- if (args.currentCategory === target) {
202
- const alreadyAtExplicit = !args.explicitTarget ||
203
- (args.currentStatusName !== undefined &&
204
- args.currentStatusName.toLowerCase() === args.explicitTarget.toLowerCase());
205
- if (alreadyAtExplicit)
206
- return { status: 'noop' };
207
- }
236
+ const alreadyAtTarget = args.explicitTarget
237
+ ? matchesStatusTarget(args.currentStatusId, args.currentStatusName, args.explicitTarget)
238
+ : args.currentCategory === target;
239
+ if (alreadyAtTarget)
240
+ return { status: 'noop' };
208
241
  const maxHops = args.maxHops ?? 5;
209
- const visited = new Set();
242
+ const visited = new Set(args.currentStatusId ? [args.currentStatusId] : []);
210
243
  const applied = [];
211
244
  let currentCategory = args.currentCategory;
245
+ let currentStatusName = args.currentStatusName;
246
+ let currentStatusId = args.currentStatusId;
212
247
  for (let hop = 0; hop < maxHops; hop++) {
213
248
  let transitions;
214
249
  try {
@@ -230,7 +265,7 @@ async function walkToCategory(args) {
230
265
  return { status: 'error', reason: err instanceof Error ? err.message : String(err) };
231
266
  }
232
267
  applied.push(direct.id);
233
- return { status: 'applied', finalCategory: target, transitions: applied };
268
+ return { status: 'applied', finalCategory: transitionCategory(direct), transitions: applied };
234
269
  }
235
270
  // No direct edge → step toward the target category. For any non-cancel walk
236
271
  // the step must avoid reject statuses so the issue never overshoots onto a
@@ -242,7 +277,7 @@ async function walkToCategory(args) {
242
277
  if (!step) {
243
278
  return {
244
279
  status: 'no_path',
245
- reason: `no workflow transition from category "${currentCategory}" toward "${target}"`,
280
+ reason: noPathReason(`no workflow transition from category "${currentCategory}" toward "${target}"`, currentStatusName, currentStatusId, args.explicitTarget, transitions),
246
281
  };
247
282
  }
248
283
  const plan = buildTransitionFields(step, args.state);
@@ -256,12 +291,19 @@ async function walkToCategory(args) {
256
291
  }
257
292
  applied.push(step.id);
258
293
  visited.add(step.to.id);
294
+ currentStatusName = step.to.name;
295
+ currentStatusId = step.to.id;
259
296
  const stepCat = transitionCategory(step);
260
297
  if (stepCat)
261
298
  currentCategory = stepCat;
262
- if (currentCategory === target) {
299
+ if (!args.explicitTarget && currentCategory === target) {
263
300
  return { status: 'applied', finalCategory: target, transitions: applied };
264
301
  }
265
302
  }
266
- return { status: 'no_path', reason: `target category "${target}" not reached within ${maxHops} hops` };
303
+ return {
304
+ status: 'no_path',
305
+ reason: noPathReason(args.explicitTarget
306
+ ? `configured status not reached within ${maxHops} hops`
307
+ : `target category "${target}" not reached within ${maxHops} hops`, currentStatusName, currentStatusId, args.explicitTarget, undefined),
308
+ };
267
309
  }