specrails-desktop 2.59.0 → 2.59.2

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-DBFt8ZHP.js → ActivityFeedPage-vAY9C71W.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-DiQx0W67.js → AgentBrowserCapture-CpwjGXQm.js} +1 -1
  3. package/client/dist/assets/{AgentModeAnalyticsPane-BmDSXp_J.js → AgentModeAnalyticsPane-BHeoJVxQ.js} +2 -2
  4. package/client/dist/assets/{AgentModeCodePane-BLQLPSwr.js → AgentModeCodePane-B0Nf3YkN.js} +2 -2
  5. package/client/dist/assets/{AgentModeJobsPane-BbTXdKUa.js → AgentModeJobsPane-CpbIItY_.js} +1 -1
  6. package/client/dist/assets/{AgentsPage-Dei1jGR8.js → AgentsPage-BVJAFXn3.js} +1 -1
  7. package/client/dist/assets/{AnalyticsPage-BhkeGIGn.js → AnalyticsPage-DDWees-K.js} +1 -1
  8. package/client/dist/assets/{CodePage-B_9yYWUx.js → CodePage-CzV5tkvS.js} +1 -1
  9. package/client/dist/assets/{DesktopAnalyticsPage-DAcfEJac.js → DesktopAnalyticsPage-DfRHNK7i.js} +1 -1
  10. package/client/dist/assets/{DocsDialog-WCuiYgsc.js → DocsDialog-BxduBYbh.js} +1 -1
  11. package/client/dist/assets/{DocsPage-bGVYTMTU.js → DocsPage-DOVeRJn_.js} +1 -1
  12. package/client/dist/assets/{ExportDropdown-CsodZ-yb.js → ExportDropdown-BnKVV8UK.js} +1 -1
  13. package/client/dist/assets/{InteractiveJobComposer-D5fh_eVL.js → InteractiveJobComposer-Da9dvM-0.js} +1 -1
  14. package/client/dist/assets/{JobDetailModal-DlpXF61O.js → JobDetailModal-DhwKbZGe.js} +1 -1
  15. package/client/dist/assets/{JobDetailPage-2yR19_Lb.js → JobDetailPage-MtPE-E_1.js} +1 -1
  16. package/client/dist/assets/{JobsPage-CS9IR6Dq.js → JobsPage-0vdlbNik.js} +1 -1
  17. package/client/dist/assets/{LoopBuilderPage-BwiAtmOr.js → LoopBuilderPage-DCUHozXh.js} +1 -1
  18. package/client/dist/assets/{LoopPreviewModal--upwvgTv.js → LoopPreviewModal-Df-S8NGf.js} +1 -1
  19. package/client/dist/assets/{LoopWindowSurface-DRnUixtl.js → LoopWindowSurface-sdb7QbSo.js} +1 -1
  20. package/client/dist/assets/{LoopsPage-Xm3nRToQ.js → LoopsPage-CDfCC9Px.js} +1 -1
  21. package/client/dist/assets/{MinimizedChatsContext-D_8L4EWZ.js → MinimizedChatsContext-DKlPf54Y.js} +1 -1
  22. package/client/dist/assets/{PluginsPage-CAFQ1B4Q.js → PluginsPage-iL0G3rQQ.js} +1 -1
  23. package/client/dist/assets/{ProjectSettingsDialog-DYSO4v90.js → ProjectSettingsDialog-DOkjT7xv.js} +1 -1
  24. package/client/dist/assets/{RepositoryDeliveries-CbQmW7Qx.js → RepositoryDeliveries-CJVpOUpZ.js} +1 -1
  25. package/client/dist/assets/RepositoryScopeSelector-BeM-2gIZ.js +2 -0
  26. package/client/dist/assets/{ReviewPacketPage-SDBhYdQB.js → ReviewPacketPage-EBCim2w_.js} +1 -1
  27. package/client/dist/assets/{TemplatePreviewModal-DHBUds51.js → TemplatePreviewModal-1-92UGwN.js} +1 -1
  28. package/client/dist/assets/{TicketDetailModalContext-UAeNm7BB.js → TicketDetailModalContext-CkVXuHxU.js} +1 -1
  29. package/client/dist/assets/{Trans-DExd3Z00.js → Trans-CzhPjnjB.js} +1 -1
  30. package/client/dist/assets/{agent-DrM4qned.js → agent--8QAfoXL.js} +1 -1
  31. package/client/dist/assets/{agent-CW74ODDP.js → agent-3HxXkIWX.js} +1 -1
  32. package/client/dist/assets/{agent-BElLtdKP.js → agent-BjttZLIl.js} +1 -1
  33. package/client/dist/assets/{agent-C-m9VugX.js → agent-CpFCz32T.js} +1 -1
  34. package/client/dist/assets/{agent-KdIXq3w5.js → agent-DVnw6ybO.js} +1 -1
  35. package/client/dist/assets/{agent-CGSKxfZk.js → agent-Z5s_GTP0.js} +1 -1
  36. package/client/dist/assets/{agent-DeCAZDhL.js → agent-hv57-zyG.js} +1 -1
  37. package/client/dist/assets/{agent-jK8mCisM.js → agent-xGrZ_aN1.js} +1 -1
  38. package/client/dist/assets/{formatDistanceToNow-aw7H5ORC.js → formatDistanceToNow-DlQaXc7c.js} +1 -1
  39. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-NyC7gZJX.js → getTimezoneOffsetInMilliseconds-DxUFGcVq.js} +1 -1
  40. package/client/dist/assets/index-Bfunvi6w.css +2 -0
  41. package/client/dist/assets/{index-JSTZVJfe.js → index-Czov72g7.js} +43 -43
  42. package/client/dist/assets/{jira-api-DlXaRwA3.js → jira-api-CbJvbRYx.js} +1 -1
  43. package/client/dist/assets/{project-repositories-DXhb9FP4.js → project-repositories-BkBD3rxx.js} +1 -1
  44. package/client/dist/assets/{spending-BdcyVkcd.js → spending-CYotH-9N.js} +1 -1
  45. package/client/dist/assets/{subscriptionUsage-DoWQv7l1.js → subscriptionUsage-BH73RO86.js} +1 -1
  46. package/client/dist/assets/{subscriptionUsage-DQ02J25O.js → subscriptionUsage-BWTeWlHx.js} +1 -1
  47. package/client/dist/assets/{subscriptionUsage-N9hB-ARf.js → subscriptionUsage-CBh82zVa.js} +1 -1
  48. package/client/dist/assets/{subscriptionUsage-DWF3zKp4.js → subscriptionUsage-CHcQ7lLe.js} +1 -1
  49. package/client/dist/assets/subscriptionUsage-CdlE5Mg1.js +1 -0
  50. package/client/dist/assets/{subscriptionUsage-Cf87DeS_.js → subscriptionUsage-DUPBmSQi.js} +1 -1
  51. package/client/dist/assets/{subscriptionUsage-DkIf6Zuh.js → subscriptionUsage-DrlwrJ17.js} +1 -1
  52. package/client/dist/assets/{subscriptionUsage-D6u7VBdz.js → subscriptionUsage-EFu0mIK3.js} +1 -1
  53. package/client/dist/assets/{useDesktop-D07TvesS.js → useDesktop-DpxVGfUC.js} +1 -1
  54. package/client/dist/assets/{useRuntimeRuns-pGLmtAC6.js → useRuntimeRuns-CfGRbhr9.js} +1 -1
  55. package/client/dist/assets/{useSharedWebSocket-TzKtPRp9.js → useSharedWebSocket-C17TGqT8.js} +2 -2
  56. package/client/dist/index.html +17 -17
  57. package/docs/internals/api-reference.md +1 -0
  58. package/docs/internals/core-runtime-updates.md +9 -2
  59. package/docs/internals/desktop-owned-workflows.md +23 -0
  60. package/docs/internals/mission-rail-cards.md +15 -0
  61. package/docs/internals/operations-runbook.md +6 -0
  62. package/docs/internals/safe-pr-review-flow.md +25 -7
  63. package/docs/internals/source-map.md +3 -1
  64. package/docs/internals/visual-design-audit.md +5 -0
  65. package/package.json +1 -1
  66. package/server/dist/core-update-manager.js +1 -1
  67. package/server/dist/db/migrations.js +9 -0
  68. package/server/dist/modules/agent-runtime/runtime/agent-runtime-bridge.js +116 -5
  69. package/server/dist/modules/delivery/runtime/multi-repo-execution.js +7 -5
  70. package/server/dist/modules/delivery/runtime/rail-isolated-launch.js +65 -30
  71. package/server/dist/modules/delivery/runtime/rail-pr-store.js +3 -3
  72. package/server/dist/modules/delivery/runtime/rail-relaunch.js +154 -0
  73. package/server/dist/modules/delivery/runtime/rails-router.js +109 -10
  74. package/server/dist/modules/execution/runtime/revision-seed.js +6 -2
  75. package/server/dist/modules/loops/runtime/loop-agent-defaults.json +1 -1
  76. package/server/dist/modules/loops/runtime/loop-definition-events.js +1 -1
  77. package/server/dist/modules/loops/runtime/loop-implement-recipe.js +14 -6
  78. package/server/dist/modules/loops/runtime/loop-run-manager.js +27 -17
  79. package/server/dist/modules/subscription-usage/adapters/claude.js +30 -20
  80. package/server/dist/modules/subscription-usage/application.js +2 -1
  81. package/server/dist/modules/subscription-usage/domain.js +35 -1
  82. package/server/dist/modules/subscription-usage/runtime/usage-service.js +1 -1
  83. package/server/dist/worktree-manager.js +114 -6
  84. package/server/dist/worktree-overlay.js +4 -4
  85. package/client/dist/assets/RepositoryScopeSelector-DKzvIGSZ.js +0 -2
  86. package/client/dist/assets/index-CahchzS5.css +0 -2
  87. package/client/dist/assets/subscriptionUsage-zRcRPHkP.js +0 -1
@@ -42,6 +42,7 @@ const accept_ladder_1 = require("../../execution/runtime/accept-ladder");
42
42
  const rail_pr_decision_1 = require("./rail-pr-decision");
43
43
  const active_pr_continuation_1 = require("./active-pr-continuation");
44
44
  const rail_isolated_launch_1 = require("./rail-isolated-launch");
45
+ const rail_relaunch_1 = require("./rail-relaunch");
45
46
  const worktree_manager_1 = require("../../../worktree-manager");
46
47
  const integration_branch_1 = require("../../../integration-branch");
47
48
  const rail_worktree_release_1 = require("./rail-worktree-release");
@@ -94,10 +95,17 @@ function prDeliveryRevisionAllowed(delivery, revisionOfDeliveryId, ticketIds) {
94
95
  return false;
95
96
  if ((0, rail_pr_store_1.isTerminalPrDecision)(delivery.decision))
96
97
  return false;
98
+ if (!hasDeliveredWork(delivery))
99
+ return false;
97
100
  const covered = new Set(delivery.ticketIds);
98
101
  const requested = new Set(ticketIds);
99
102
  return requested.size > 0 && requested.size === covered.size && [...requested].every((id) => covered.has(id));
100
103
  }
104
+ function hasDeliveredWork(delivery) {
105
+ return Boolean((delivery.branch && delivery.deliverySha && ((delivery.prUrl && delivery.prState === 'pr-created') || (delivery.implementationOutcome !== 'failed' && delivery.deliveryOutcome !== 'blocked' && delivery.deliveryOutcome !== 'not_started' && delivery.deliveryOutcome !== 'no_changes')))
106
+ || delivery.branches?.some(unit => unit.succeeded && unit.finalSha && unit.changed !== false && unit.implementationOutcome !== 'failed' && unit.deliveryOutcome !== 'blocked' && unit.deliveryOutcome !== 'not_started' && unit.deliveryOutcome !== 'no_changes')
107
+ || delivery.repositoryDeliveries?.some(unit => unit.branch && unit.deliverySha && unit.implementationOutcome !== 'failed' && unit.deliveryOutcome !== 'blocked' && unit.deliveryOutcome !== 'not_started' && unit.deliveryOutcome !== 'no_changes'));
108
+ }
101
109
  function prDeliveryCheckoutTarget(delivery) {
102
110
  if (delivery.branch && delivery.deliverySha)
103
111
  return { branch: delivery.branch, sha: delivery.deliverySha };
@@ -493,13 +501,47 @@ function createRailsRouter() {
493
501
  res.status(500).json({ error: 'Failed to update rail name' });
494
502
  }
495
503
  });
496
- // POST /rails/:railIndex/launch — launch job(s) for a rail
497
- router.post('/:railIndex/launch', async (req, res) => {
504
+ // Both doors share admission checks; Relaunch resolves its original source
505
+ // before interpreting launch options, never the mutable slot's new specs.
506
+ router.post(['/:railIndex/launch', '/:railIndex/relaunch'], async (req, res) => {
498
507
  const railIndex = parseInt(req.params.railIndex, 10);
499
508
  if (isNaN(railIndex) || railIndex < 0 || railIndex >= rails_store_1.MAX_RAILS) {
500
509
  res.status(400).json({ error: 'Invalid rail index' });
501
510
  return;
502
511
  }
512
+ const c = ctx(req);
513
+ let relaunchSource;
514
+ if (/\/relaunch\/?$/i.test(req.path)) {
515
+ try {
516
+ if (!(0, feature_flags_1.isLoopsEnabled)()) {
517
+ res.status(409).json({ error: 'relaunch_workflow_unavailable', detail: 'The original loop workflow cannot run while Loops are disabled.',
518
+ action: 'Enable Loops before retrying this attempt.' });
519
+ return;
520
+ }
521
+ relaunchSource = (0, rail_relaunch_1.resolveRailRelaunch)(c, railIndex, req.body?.sourceId);
522
+ if (relaunchSource.delivery && !(0, rail_isolation_1.isRailPrDeliveryEnabled)()) {
523
+ res.status(409).json({ error: 'relaunch_delivery_unavailable', detail: 'The original attempt belongs to isolated PR delivery, which is disabled.',
524
+ action: 'Enable PR delivery before retrying this attempt.' });
525
+ return;
526
+ }
527
+ if (relaunchSource.delivery && hasDeliveredWork(relaunchSource.delivery)) {
528
+ res.status(409).json({ error: 'relaunch_requires_recovery',
529
+ detail: 'This attempt has delivered work that must be recovered before it can be retried.',
530
+ action: 'Use Recover & retry or the delivery actions on this card.' });
531
+ return;
532
+ }
533
+ req.body = { ...relaunchSource.config,
534
+ originConversationId: req.body?.originConversationId ?? relaunchSource.delivery?.originConversationId,
535
+ originSurface: req.body?.originSurface ?? relaunchSource.delivery?.originSurface ?? 'dashboard',
536
+ };
537
+ }
538
+ catch (error) {
539
+ res.status(409).json({ error: error instanceof rail_relaunch_1.RailRelaunchError ? error.code : 'relaunch_config_unavailable',
540
+ detail: error instanceof Error ? error.message : String(error),
541
+ action: error instanceof rail_relaunch_1.RailRelaunchError ? error.action : 'Configure a fresh launch on a free rail.' });
542
+ return;
543
+ }
544
+ }
503
545
  let runtimeProviderOverride;
504
546
  try {
505
547
  runtimeProviderOverride = (0, agent_runtime_settings_1.validateRuntimeProviderOverride)(req.body?.runtimeProviderOverride);
@@ -597,6 +639,8 @@ function createRailsRouter() {
597
639
  loopId = (0, loop_factory_1.getFactoryLoop)(loopId).id; // canonicalize saved legacy aliases
598
640
  mode = fmode;
599
641
  }
642
+ if (relaunchSource)
643
+ relaunchSource.config = { ...relaunchSource.config, mode };
600
644
  if (!VALID_MODES.has(mode)) {
601
645
  res.status(400).json({ error: 'mode must be "implement", "freestyle" or "loop"' });
602
646
  return;
@@ -634,7 +678,6 @@ function createRailsRouter() {
634
678
  // spawn-time gate in QueueManager (kill-switch + persistent-stdin
635
679
  // capability) decides, so the launch no longer passes an explicit flag. A
636
680
  // legacy `interactive` body param is accepted and ignored (wire compat).
637
- const c = ctx(req);
638
681
  try {
639
682
  (0, process_admission_1.assertProcessAdmission)(c.project.id);
640
683
  }
@@ -645,7 +688,15 @@ function createRailsRouter() {
645
688
  }
646
689
  throw err;
647
690
  }
648
- const rail = (0, rails_store_1.getRail)(c.db, railIndex);
691
+ const storedRail = (0, rails_store_1.getRail)(c.db, railIndex);
692
+ const rail = relaunchSource ? { ...storedRail, ticketIds: [...relaunchSource.ticketIds],
693
+ profileName: typeof profileName === 'string' ? profileName : null,
694
+ aiEngine: typeof aiEngine === 'string' ? aiEngine : null } : storedRail;
695
+ if (relaunchSource && rail.ticketIds.some(id => !c.getTicketSpec?.(id))) {
696
+ res.status(409).json({ error: 'relaunch_specs_unavailable', detail: 'One or more original specs are no longer available.',
697
+ action: 'Refresh the mission and configure a launch with the current specs.' });
698
+ return;
699
+ }
649
700
  if (rail.ticketIds.length === 0) {
650
701
  res.status(400).json({ error: 'Rail has no tickets assigned' });
651
702
  return;
@@ -676,6 +727,10 @@ function createRailsRouter() {
676
727
  return;
677
728
  }
678
729
  let repositoryBases;
730
+ if (relaunchSource?.delivery?.executionManifest && baseDeliveryIds === undefined) {
731
+ repositoryBases = { repositoryBaseBranches: Object.fromEntries(relaunchSource.delivery.executionManifest.repositories.map(repo => [repo.repositoryId, repo.baseBranch])), repositoryBaseShas: {} };
732
+ baseBranch = repositoryBases.repositoryBaseBranches[primaryRepository.id] ?? baseBranch;
733
+ }
679
734
  if (baseDeliveryIds !== undefined) {
680
735
  try {
681
736
  repositoryBases = await (0, multi_repo_bases_1.resolveRepositoryDeliveryBases)(c.db, c.project, repositoryIds, baseDeliveryIds, worktree_manager_1.defaultGitRunner);
@@ -983,6 +1038,18 @@ function createRailsRouter() {
983
1038
  return;
984
1039
  }
985
1040
  const scope = loopGraph.config.ticketScope ?? (loopGraph.config.journal === 'implementation' ? 'all' : (0, loop_command_catalog_1.dominantTicketScope)(promptsText));
1041
+ // Freeze only admitted launch choices. Execution paths, run ids,
1042
+ // addendum claims and revision generation ids must never be replayed.
1043
+ const launchConfig = {
1044
+ mode, loopId, repositoryIds, workspaceSelection,
1045
+ aiEngine: rolesMode ? loop_role_engines_1.ROLES_ENGINE : loopProvider, model: loopModel,
1046
+ reasoning_effort: effort, profileName: resolvedProfile,
1047
+ ...(runtimeProviderOverride ? { runtimeProviderOverride } : {}),
1048
+ ...(baseBranch ? { baseBranch } : {}),
1049
+ ...(baseDeliveryIds ? { baseDeliveryIds } : {}),
1050
+ ...(typeof targetPrNumber === 'number' ? { targetPrNumber } : {}),
1051
+ ...(followUp ? { followUp } : {}),
1052
+ };
986
1053
  // Parallel isolation (default-on; disable with SPECRAILS_RAIL_WORKTREES=0):
987
1054
  // a per-ticket rail on a repo-mutating loop runs each ticket in its own git
988
1055
  // worktree, then merges the branches back. A non-git repo or an unborn
@@ -991,6 +1058,7 @@ function createRailsRouter() {
991
1058
  let isolationUnavailable;
992
1059
  let continuablePrDelivery = null;
993
1060
  let revisionRequest = null;
1061
+ let retryOfDelivery = null;
994
1062
  // Read-only vs mutating is DERIVED from the loop's nodes (see loop-effect),
995
1063
  // not a user flag — a content-read-only loop (no ai-step/shell) never writes,
996
1064
  // so it is not isolated; anything that can write is.
@@ -1006,7 +1074,7 @@ function createRailsRouter() {
1006
1074
  // `pr_decision_pending` — the user already saw the failure on the card,
1007
1075
  // and pressing Launch again IS the decision. A revision naming it is
1008
1076
  // meaningless (there is no work to revise) and gets a precise 409.
1009
- if (pending && (0, rail_pr_store_1.isPreparationFailureRow)(pending)) {
1077
+ if (pending && !relaunchSource && (0, rail_pr_store_1.isPreparationFailureRow)(pending)) {
1010
1078
  if (revisionOfDeliveryId === pending.id) {
1011
1079
  res.status(409).json({
1012
1080
  error: 'invalid_revision_target',
@@ -1029,6 +1097,15 @@ function createRailsRouter() {
1029
1097
  // Explicit change requests name it; open addenda on the same rail
1030
1098
  // identify it implicitly. Both use the existing supersession contract.
1031
1099
  const revisionsEnabled = (0, feature_flags_1.areDeliveryRevisionsEnabled)();
1100
+ const failedAddendumRetry = Boolean(launchAddenda.length && pendingSnapshot && !revisionOfDeliveryId
1101
+ && ['implementation_failed', 'pr_failed'].includes(pendingSnapshot.decision)
1102
+ && !pendingSnapshot.prUrl && !hasDeliveredWork(pendingSnapshot)
1103
+ && new Set(pendingSnapshot.ticketIds).size === new Set(rail.ticketIds).size
1104
+ && rail.ticketIds.every(id => pendingSnapshot.ticketIds.includes(id)));
1105
+ const identifiedFailureRetry = Boolean(relaunchSource?.delivery && pendingSnapshot
1106
+ && relaunchSource.sourceId === pendingSnapshot.id
1107
+ && ['implementation_failed', 'pr_failed'].includes(pendingSnapshot.decision)
1108
+ && !hasDeliveredWork(pendingSnapshot));
1032
1109
  const addendumContinuation = Boolean(launchAddenda.length && pendingSnapshot && pendingSnapshot.decision !== 'building'
1033
1110
  && !revisionOfDeliveryId
1034
1111
  && prDeliveryRevisionAllowed(pendingSnapshot, pendingSnapshot.id, rail.ticketIds));
@@ -1042,7 +1119,7 @@ function createRailsRouter() {
1042
1119
  });
1043
1120
  return;
1044
1121
  }
1045
- if (pendingSnapshot && !revisionOfPending && !prDeliveryContinuesTickets(pendingSnapshot, rail.ticketIds)) {
1122
+ if (pendingSnapshot && !revisionOfPending && !failedAddendumRetry && !identifiedFailureRetry && !prDeliveryContinuesTickets(pendingSnapshot, rail.ticketIds)) {
1046
1123
  res.status(409).json({ error: 'pr_decision_pending', prDeliveryId: pendingSnapshot.id });
1047
1124
  return;
1048
1125
  }
@@ -1055,11 +1132,13 @@ function createRailsRouter() {
1055
1132
  : revisionNote,
1056
1133
  };
1057
1134
  }
1135
+ if ((failedAddendumRetry || identifiedFailureRetry) && pendingSnapshot)
1136
+ retryOfDelivery = { deliveryId: pendingSnapshot.id, decision: pendingSnapshot.decision };
1058
1137
  // Explicit target vs an undecided continuable delivery: the slot's
1059
1138
  // active generation owns its PR. A DIFFERENT explicit target would
1060
1139
  // append to the undecided branches → 409; the SAME PR is redundant
1061
1140
  // (the continuation contract below already drives it) and is dropped.
1062
- if (pendingSnapshot && typeof targetPrNumber === 'number' && pendingSnapshot.prNumber !== targetPrNumber
1141
+ if (pendingSnapshot && !retryOfDelivery && typeof targetPrNumber === 'number' && pendingSnapshot.prNumber !== targetPrNumber
1063
1142
  && !pendingSnapshot.repositoryDeliveries?.some((delivery) => delivery.prNumber === targetPrNumber)) {
1064
1143
  res.status(409).json({ error: 'pr_decision_pending', prDeliveryId: pendingSnapshot.id });
1065
1144
  return;
@@ -1080,6 +1159,11 @@ function createRailsRouter() {
1080
1159
  res.status(409).json({ error: 'repository_isolation_required', detail: 'Selected repositories require worktree isolation for this loop.' });
1081
1160
  return;
1082
1161
  }
1162
+ if (relaunchSource?.delivery && !useIsolation) {
1163
+ res.status(409).json({ error: 'relaunch_isolation_required', detail: 'The original attempt was isolated; its retry must also use an isolated worktree.',
1164
+ action: 'Restore worktree isolation and the original workflow before retrying.' });
1165
+ return;
1166
+ }
1083
1167
  if (typeof targetPrNumber === 'number' && !useIsolation) {
1084
1168
  res.status(400).json({ error: 'target_pr_requires_pr_mode', detail: 'targetPrNumber requires an isolated (worktree) launch; this launch would run in the shared checkout' });
1085
1169
  return;
@@ -1133,7 +1217,10 @@ function createRailsRouter() {
1133
1217
  }
1134
1218
  }
1135
1219
  try {
1220
+ if (relaunchSource && (0, rail_relaunch_1.restoreRelaunchRail)(c, railIndex, relaunchSource))
1221
+ broadcastRailUpdated(c, railIndex, 'tickets');
1136
1222
  const ids = await (0, rail_isolated_launch_1.launchIsolatedRail)({
1223
+ launchConfig,
1137
1224
  runtimeProviderOverride,
1138
1225
  ctx: c, railIndex, ticketIds: [...rail.ticketIds], repositoryIds, workspaceSelection, ...repositoryBases, loopId, loopName, loopGraph,
1139
1226
  provider: loopProvider, model: loopModel, effort, scope,
@@ -1142,7 +1229,7 @@ function createRailsRouter() {
1142
1229
  originSurface: originSurface ?? 'dashboard',
1143
1230
  originConversationId: originConversationId ?? null,
1144
1231
  ...(baseBranch ? { baseBranch } : {}),
1145
- ...(continuablePrDelivery?.executionManifest && !revisionRequest ? { repositoryContinuation: { deliveryId: continuablePrDelivery.id, decision: continuablePrDelivery.decision } } : {}),
1232
+ ...(continuablePrDelivery?.executionManifest && !revisionRequest && !retryOfDelivery ? { repositoryContinuation: { deliveryId: continuablePrDelivery.id, decision: continuablePrDelivery.decision } } : {}),
1146
1233
  ...(continuablePrDelivery && !continuablePrDelivery.executionManifest && prDeliveryContinuesTickets(continuablePrDelivery, rail.ticketIds) ? {
1147
1234
  requiredPrContinuation: {
1148
1235
  deliveryId: continuablePrDelivery.id,
@@ -1157,18 +1244,23 @@ function createRailsRouter() {
1157
1244
  // Explicit target only drives fresh launches; when the slot has
1158
1245
  // a continuable delivery for the same PR, the continuation
1159
1246
  // contract above is the (stricter) authority.
1160
- ...(typeof targetPrNumber === 'number' && !continuablePrDelivery
1247
+ ...(typeof targetPrNumber === 'number' && (!continuablePrDelivery || retryOfDelivery)
1161
1248
  ? { explicitPrTarget: { prNumber: targetPrNumber } }
1162
1249
  : {}),
1163
1250
  // Revision of an undecided delivery: the guard above proved the
1164
1251
  // exemption, so pass the contract through for supersession.
1165
1252
  ...(revisionRequest ? { revision: revisionRequest } : {}),
1253
+ ...(retryOfDelivery ? { retryOfDelivery } : {}),
1166
1254
  ...(followUp ? { followUp } : {}),
1167
1255
  });
1168
1256
  res.status(202).json({ loopRunIds: ids, railIndex, mode, loopId, prDeliveryId: (0, rail_pr_store_1.getActivePrDeliveryByRail)(c.db, railIndex)?.id ?? null, isolated: true, ...(followUp ? { followUp: { id: followUp.id, version: followUp.version, hash: followUp.hash } } : {}) });
1169
1257
  return;
1170
1258
  }
1171
1259
  catch (err) {
1260
+ if (err instanceof rail_relaunch_1.RailRelaunchError) {
1261
+ res.status(409).json({ error: err.code, detail: err.message, action: err.action });
1262
+ return;
1263
+ }
1172
1264
  if (err instanceof rail_pr_store_1.PrDeliveryGenerationConflict) {
1173
1265
  res.status(409).json({
1174
1266
  error: 'pr_delivery_generation_conflict',
@@ -1186,7 +1278,7 @@ function createRailsRouter() {
1186
1278
  });
1187
1279
  return;
1188
1280
  }
1189
- if (continuablePrDelivery || err instanceof rail_isolated_launch_1.PrContinuationIsolationError) {
1281
+ if ((continuablePrDelivery && !retryOfDelivery) || err instanceof rail_isolated_launch_1.PrContinuationIsolationError) {
1190
1282
  const detail = err instanceof Error ? err.message : String(err);
1191
1283
  res.status(409).json({
1192
1284
  error: 'pr_continuation_isolation_required',
@@ -1208,6 +1300,8 @@ function createRailsRouter() {
1208
1300
  // Spawn from the SAME cwd a rail uses (workspace when relocated, else the
1209
1301
  // repo) so native `{{cmd:*}}` slash commands resolve — and surface the repo
1210
1302
  // via SPECRAILS_REPO_DIR + `--add-dir` exactly like QueueManager.
1303
+ if (relaunchSource && (0, rail_relaunch_1.restoreRelaunchRail)(c, railIndex, relaunchSource))
1304
+ broadcastRailUpdated(c, railIndex, 'tickets');
1211
1305
  const loopExec = (0, workspace_resolution_1.resolveProjectExecution)({ slug: c.project.slug, path: c.project.path });
1212
1306
  const loopRunIds = [];
1213
1307
  const loopTicketCompletionStatus = (0, rail_isolation_1.isRailPrDeliveryEnabled)() ? 'on_review' : 'done';
@@ -1270,6 +1364,7 @@ function createRailsRouter() {
1270
1364
  };
1271
1365
  const runPromise = c.loopRunManager
1272
1366
  .run({
1367
+ launchConfig,
1273
1368
  runtimeProviderOverride,
1274
1369
  runId,
1275
1370
  loopId,
@@ -1414,6 +1509,10 @@ function createRailsRouter() {
1414
1509
  res.status(202).json({ jobId, railIndex, mode });
1415
1510
  }
1416
1511
  catch (err) {
1512
+ if (err instanceof rail_relaunch_1.RailRelaunchError) {
1513
+ res.status(409).json({ error: err.code, detail: err.message, action: err.action });
1514
+ return;
1515
+ }
1417
1516
  if (err instanceof queue_manager_1.ClaudeNotFoundError) {
1418
1517
  res.status(503).json({ error: 'Claude CLI not found' });
1419
1518
  return;
@@ -46,8 +46,12 @@ function buildRevisionSeed(input) {
46
46
  return [
47
47
  '## Revision briefing',
48
48
  '',
49
- `This is revision ${input.revisionNumber} of work that has ALREADY been delivered for the spec(s) below.`,
50
- 'The code exists on the branch listed here. Do NOT start over, re-plan, or re-implement it.',
49
+ input.branches.length > 0
50
+ ? `This is revision ${input.revisionNumber} of recorded branch work for the spec(s) below.`
51
+ : `This is attempt ${input.revisionNumber} for the spec(s) below; no delivered branch was recorded.`,
52
+ input.branches.length > 0
53
+ ? 'Inspect the recorded branch first. Preserve its existing implementation and apply the requested changes.'
54
+ : 'Implement the full spec together with the requested changes; do not assume prior implementation exists.',
51
55
  '',
52
56
  '## What the user asked to change',
53
57
  '',
@@ -2,5 +2,5 @@
2
2
  "architect": "## Your task: architecture\n\nYou are the Specrails architect. Turn the requested work into an unambiguous, implementable plan that a developer agent will execute without talking to you.\n\n1. Orient quickly: locate the code the change touches, the tests that cover it and the conventions that apply. Calibrate depth to the blast radius. A localized change gets a short proposal, focused design and only the tasks it needs; a cross-cutting change earns a full impact analysis.\nIn design.md include a Local reference patterns section: cite actual repository paths and symbols for the closest existing implementation and its tests. Explain async rendering/state updates, error propagation and test setup when relevant. Check installed framework versions. If no equivalent exists, state that explicitly; never invent references.\n2. Decide the approach, name the exact files/modules to create or change (inside the repository scope when one is declared), and call out risks, edge cases and compatibility concerns. Plan only the change the requested work needs; unrelated clean-ups are out of scope.\n3. Break the work into ordered, atomic tasks. Each task names concrete files and includes its own tests. Every task must be completable by an agent that can only edit files and run commands: never add tasks such as \"run the test suite\", \"verify\", \"test manually in a browser\", \"commit\" or \"open a PR\". Core runs verification and the host owns delivery.\n4. Execute the official OpenSpec fast-forward skill. Query status and instructions in dependency order; author the real delta specs and other artifacts using those templates and project rules. Preserve existing requirements through OpenSpec delta semantics. Do not fabricate complete replacement specs.\n5. Propose verification. For each repository listed below without a configured verification command, name the existing command that proves the change: the project's test script, type check, build or lint (for example `npm` with args `[\"test\"]`, or `cargo` with `[\"test\"]`). Only propose commands that exist in the repository today or that a task in this plan adds; omit repositories where nothing automated applies. For a repository with a scope, the command must exercise that scope: omit cwd (it then runs in the scope directory) or name a directory inside it; never a command that runs every workspace of a larger checkout. Core runs them after the developer finishes and feeds failures back.\n6. Score your confidence honestly: `high` when the code evidence is conclusive; `medium` when the design rests on one non-obvious assumption (name it in the design); `low` when several plausible designs exist and you cannot choose without missing information. With `low`, put the single question whose answer decides the design in `question`. Core first lets you investigate further, then either asks the requester that exact question or proceeds on your stated assumptions, depending on the project configuration.",
3
3
  "developer": "## Your task: implementation\n\nYou are the Specrails developer. Execute the official openspec-apply-change skill for the approved change, implement its pending tasks completely and update their progress through that workflow. Do not substitute a hand-written approximation of apply.\n\n1. Load and execute openspec-apply-change through the supplied binding. Consult its status and instructions apply, read the context files OpenSpec returns, then the relevant existing code and tests.\n2. Work task by task in order. Use test-driven development: write or extend the test first, make it pass with the smallest correct change, then tidy up. Run only focused tests that cover what you touched while iterating. Core owns the complete verification plan and runs it after your turn; do not duplicate that full run. Fix the precise failures Core returns on a correction pass.\n3. Immediately after completing each task, mark it `- [x]` in `tasks.md`; do not postpone all progress updates until the end of the phase. Only mark tasks whose code and tests are complete. Change nothing else in `tasks.md`, and never edit `proposal.md`, `design.md` or the specs: those documents are frozen, and editing them invalidates the run. If a task cannot be completed, leave it `- [ ]` and list it under `incomplete` with the reason.\n4. Keep the implementation consistent with the repository: naming, error handling, import style, formatting and existing utilities. Do not add dependencies unless the design requires them. Change only what the tasks require: the diff should contain the requested change and its tests, nothing else.\nInvestigation budget: consult the local reference patterns in design.md and equivalent application tests before framework internals or node_modules. After three unsuccessful experiments on the same failure, stop repeating commands: state the hypothesis, evidence and next discriminating experiment, then change approach. If three further experiments add no evidence, report the specific blocker under incomplete rather than consuming the remaining turn budget. Never weaken assertions or change acceptance criteria to make a test pass.\nVerification evidence: run tests without piping their output through grep/head or other filters that mask the original exit status. Capture complete stdout/stderr in a temporary log and preserve the test process exit code; inspect that log separately. Report the command and original exit code. Remove temporary debug tests before finishing. Core independently runs the final verification commands.\n5. If the shell is unavailable, still finish every task that only needs code and tests; Core runs the verification commands after your turn and returns the exact failures to you.\n\n## Durable implementation progress\n\nWhen loading the official apply skill, read its savedProgress handoff before repeating investigation. Reconcile it with the current diff and tasks; it is advisory history, never accepted verification evidence. Preserve completed work and previously discovered test commands or environment blockers. Recheck a blocker only when its relevant condition changed.\nAfter each completed task, and whenever the next action or a blocker changes, call the scoped workflow action write_progress with progress: {summary, completedTasks, nextTasks, checks: [{command, outcome}], blockers}. Replace the previous handoff with a concise current account (at most 8 KB); include exact focused test commands, original exit outcomes, relevant paths and the next discriminating action. Keep nextTasks concrete enough for a fresh session to continue unfinished work. Do not include full logs, credentials or claims that old checks authorize acceptance. The host persists this record outside the frozen OpenSpec artifacts. Use read_progress to refresh it. If interrupted, the next session receives it from load_skill; do not write runtime files directly.",
4
4
  "reviewer": "## Your task: review\n\nYou are the Specrails reviewer and the last gate before this change is archived. Inspect the implementation, the approved artifacts and the verification evidence read-only. Do not fix anything.\n\nCheck, in this order:\n1. Spec completeness: every requirement in the change specs and every acceptance criterion is implemented. Cross-reference each one against the code.\n2. Task completion: every task in `tasks.md` is `- [x]` and is backed by real code and tests, not just a ticked box.\n3. Test quality: new behavior has tests that assert on behavior, cover error paths, and would fail without the change. Missing tests for production code are a blocking issue.\n4. Correctness and conventions: types and signatures fit the codebase, patterns match the repository, imports and error handling are consistent, no unrelated changes.\n5. Security: no secrets, injection, path traversal, unsafe deserialization, missing authorization or new attack surface. Scale scrutiny to what the change touches.\n6. Performance: no obvious N+1, unbounded loops or blocking work on hot paths introduced by the change.\n\nJudge BEHAVIOUR against the acceptance criteria, never the shape of the code against the wording of the plan: module layout, file lists, class or function names, naming conventions and implementation techniques named in the ticket, the design or a \"contract layer\" are suggestions the developer may legitimately improve on. A working implementation that satisfies a criterion by other means is correct; asking to rename, move or rewrite it to match the plan's wording is NOT an issue. Only a criterion that is not met, a defect, a missing test or a regression is.\n\nReview THE CHANGE. The change set below lists every file this run changed relative to its base, measured by Core from git. Judge those changes and the behavior they affect; code the change did not touch is not an issue unless the change breaks it. Never ask for edits to pre-existing code, other packages or workspaces, or configuration outside the change. An environment problem (credentials, registry access, missing tools) is not a code issue: record it under findings, never as an issue for the fixer.\n\nThe verification evidence below comes from real subprocesses run by Core after the developer finished; treat it as fact, not as a claim by the developer.",
5
- "fixer": "## Your task: correction\n\nYou are the Specrails FIXER. The verification commands (or the review) failed after the developer's pass; your only job is to make them pass with minimal, precise edits to this change.\n\n1. Read the failing output below first: the exact command, its exit code and the reported files and lines. Start from those files, not from the plan.\n2. Decide whether this change caused the failure. It did when the failure points at files in the change set below or at behavior they affect. A failure in code the change never touched (another package or workspace, a pre-existing test), in the environment (missing credentials, environment variables, registry access or tools) or in configuration outside the change is NOT yours to fix: edit nothing for it, and report it under `incomplete` with the exact error. Core then stops and asks the host instead of starting another round.\n3. Patch exactly the files and lines the failure names; touch neighbouring code only when the failure cannot be fixed otherwise. Rewrite a file only when a patch cannot express the change.\n4. Never re-implement a feature, rename or restructure, reformat, add dependencies, or widen the change beyond the failure. Never weaken an assertion, delete a test or change acceptance criteria to make a check pass — if a test is wrong, fix the test to the specification and say so.\n5. A test file the host reports as never executed must be wired into the repository's test command (the test script or runner configuration) and then made to pass.\n6. Confirm with the narrowest command that reproduces the failure (the failing test file or test name). Do not re-run whole suites or other workspaces: Core re-runs the complete verification plan after your turn.\n7. Do not read the repository beyond the files the failure names and their direct dependencies. `proposal.md`, `design.md` and the specs are frozen; in `tasks.md` only tick a task you completed.\n8. Finish with the same JSON summary object as the developer, listing under `incomplete` only failures you could not fix and why.\n\n## Durable implementation progress\n\nWhen loading the official apply skill, read its savedProgress handoff before repeating investigation. Reconcile it with the current diff and tasks; it is advisory history, never accepted verification evidence. Preserve completed work and previously discovered test commands or environment blockers. Recheck a blocker only when its relevant condition changed.\nAfter each completed task, and whenever the next action or a blocker changes, call the scoped workflow action write_progress with progress: {summary, completedTasks, nextTasks, checks: [{command, outcome}], blockers}. Replace the previous handoff with a concise current account (at most 8 KB); include exact focused test commands, original exit outcomes, relevant paths and the next discriminating action. Keep nextTasks concrete enough for a fresh session to continue unfinished work. Do not include full logs, credentials or claims that old checks authorize acceptance. The host persists this record outside the frozen OpenSpec artifacts. Use read_progress to refresh it. If interrupted, the next session receives it from load_skill; do not write runtime files directly."
5
+ "fixer": "## Your task: correction\n\nYou are the Specrails FIXER. The verification commands (or the review) failed after the developer's pass; your only job is to make them pass with minimal, precise edits to this change.\n\n1. Read the host failure facts first: command, cwd, original exit code, failureSummary, expected assertion and application file/line. If the excerpt is truncated, use read_verification_evidence with the command's evidenceId for complete stdout/stderr and source. Read the failing test and the implementation it exercises.\n2. Reproduce the failure with the narrowest test command. Compare the assertion, intended behavior, current implementation and actual diff. An unchanged file or a pre-existing test does not prove the failure is unrelated: changes can break existing tests indirectly. Classify the cause as an implementation defect, a test compatibility assumption, or an external/scope blocker. A claim that the failure existed before this change needs baseline or historical evidence; otherwise state that it is unconfirmed.\n3. Patch exactly the files and lines the failure names; touch neighbouring code only when the failure cannot be fixed otherwise. Rewrite a file only when a patch cannot express the change.\n4. Fix incorrect implementation behavior. A failing mandatory host test inside the admitted repository/workspace may also receive a minimal compatibility repair when you prove that its expected behavior is unchanged. For example, a source assertion broken only by formatting may tolerate whitespace while retaining every required operand and guard. This applies even to a pre-existing test outside the current changed-file list. Explain the original expectation and why the repair still rejects the prohibited behavior. Never weaken an assertion, delete or skip a test, hardcode success, change acceptance criteria, add dependencies or widen the repair beyond the diagnosed failure.\n5. A test file the host reports as never executed must be wired into the repository's test command (the test script or runner configuration) and then made to pass.\n6. Confirm the repair with the focused test command and a negative case for any repaired assertion: removing a required safety guard or returning the wrong value must still fail. Preserve the original test exit status; never pipe test execution through grep/head or another command that masks it. Capture stdout/stderr in a temporary log and inspect that log separately. Core runs the complete verification plan after your turn.\n7. Inspect only the reported files, direct dependencies and the evidence needed to establish the cause. Never edit outside the admitted repository/workspace, credentials, environment or unrelated configuration. For an external or unrepairable scope blocker, make no speculative edits and report the exact error, observed evidence and required action. `proposal.md`, `design.md` and the specs remain frozen; only tick completed tasks.\n8. Return the requested JSON contract. Include the diagnosis, exact focused commands and original exit codes, repair and preserved assertion in summary. List every unresolved failure and its reason under incomplete, even when the implementation tasks are already ticked. A failed test remains failed until real host verification passes.\n\n## Durable implementation progress\n\nWhen loading the official apply skill, read its savedProgress handoff before repeating investigation. Reconcile it with the current diff and tasks; it is advisory history, never accepted verification evidence. Preserve completed work and previously discovered test commands or environment blockers. Recheck a blocker only when its relevant condition changed.\nAfter each completed task, and whenever the next action or a blocker changes, call the scoped workflow action write_progress with progress: {summary, completedTasks, nextTasks, checks: [{command, outcome}], blockers}. Replace the previous handoff with a concise current account (at most 8 KB); include exact focused test commands, original exit outcomes, relevant paths and the next discriminating action. Keep nextTasks concrete enough for a fresh session to continue unfinished work. Do not include full logs, credentials or claims that old checks authorize acceptance. The host persists this record outside the frozen OpenSpec artifacts. Use read_progress to refresh it. If interrupted, the next session receives it from load_skill; do not write runtime files directly."
6
6
  }
@@ -129,7 +129,7 @@ function createDefinitionEventProjection(input) {
129
129
  persist('loop_step', { index: nextCount, kind: 'core', title: workflow.nodePath, iteration: workflow.visit, startedAtMs: Date.parse(at), ...correlation });
130
130
  }
131
131
  else if (opening && ['step_succeeded', 'step_failed', 'step_blocked', 'step_interrupted', 'step_retrying', 'step_paused'].includes(String(workflow.type))) {
132
- persist('loop_step_end', { index: opening.index, status: workflow.type === 'step_succeeded' ? 'ok' : workflow.type === 'step_paused' ? 'paused' : workflow.type === 'step_interrupted' ? 'interrupted' : 'failed',
132
+ persist('loop_step_end', { index: opening.index, status: workflow.type === 'step_succeeded' ? workflow.outcome === 'fail' ? 'failed' : 'ok' : workflow.type === 'step_paused' ? 'paused' : workflow.type === 'step_interrupted' ? 'interrupted' : 'failed',
133
133
  runtimeStatus: String(workflow.type).slice(5), outcome: workflow.outcome, exitCode: null,
134
134
  durationMs: Math.max(0, Date.parse(at) - Number(opening.startedAtMs)), ...correlation });
135
135
  }
@@ -7,6 +7,7 @@ const string = { type: 'string' };
7
7
  const strings = { type: 'array', items: string, maxItems: 100 };
8
8
  const score = { type: 'number', minimum: 0, maximum: 100 };
9
9
  const aspects = { type_correctness: 60, pattern_adherence: 60, test_coverage: 60, security: 75, architectural_alignment: 60 };
10
+ const reviewPolicy = 'approved == true; issues must be empty; score >= 70; ' + Object.entries(aspects).map(([aspect, threshold]) => `${aspect} >= ${threshold}`).join('; ');
10
11
  /** The recipe, roles, schemas and decisions are Desktop data, never Core phases. */
11
12
  function configurableImplementGraph() {
12
13
  const agents = (0, loop_agents_1.defaultLoopAgents)();
@@ -31,19 +32,26 @@ function configurableImplementGraph() {
31
32
  node('clarify', 'Clarify plan', 'question', { text: '{{outputs.architect.structured.question}}' }, { next: 'reset-confidence' });
32
33
  node('reset-confidence', 'Reset confidence after answer', 'assign', { set: { planInvestigated: false } }, { next: 'architect', failed: 'failed' });
33
34
  node('validate', 'Validate OpenSpec', 'openspec-validate', { change }, { pass: 'freeze', fail: 'architect', failed: 'failed' });
34
- node('freeze', 'Freeze approved artifacts', 'artifact-contract', { change, contractId: 'plan', action: 'freeze' }, { pass: 'developer', fail: 'architect', failed: 'failed' });
35
+ node('freeze', 'Freeze approved artifacts', 'artifact-contract', { change, contractId: 'plan', action: 'freeze' }, { pass: 'initialize-corrections', fail: 'architect', failed: 'failed' });
36
+ node('initialize-corrections', 'Initialize automatic correction budget', 'assign', { set: { correctionAttempts: 0, correctionLimit: 3 } }, { next: 'developer', failed: 'failed' });
35
37
  const implementation = objectSchema({ summary: string, incomplete: strings });
36
38
  turn('developer', 'Developer', 'build', `Implement every approved task for change ${change} using the bound official apply skill. Preserve proposal, design and specs; only tick tasks you completed. Report summary and incomplete task descriptions. Prior execution evidence and human answers: {{history.text}}`, implementation, 'tasks');
37
39
  node('tasks', 'Check artifacts and completed tasks', 'artifact-contract', { change, contractId: 'plan', action: 'check', requireCompletedTasks: true }, { pass: 'verify', fail: 'developer', failed: 'failed' });
38
- node('verify', 'Verify candidate', 'verify', { commands: 'configured', additionalCommandsFrom: 'architect' }, { pass: 'reviewer', fail: 'fixer', failed: 'failed' });
39
- turn('reviewer', 'Reviewer', 'assess', `Review change ${change} read-only against all approved artifacts and frozen ticket acceptance criteria. Inspect real code and the host verification evidence: {{outputs.verify}}. Return approved, summary, issues, score (0-100), and aspects. Prior execution: {{history.text}}`, objectSchema({ approved: { type: 'boolean' }, summary: string, issues: strings, score, aspects: objectSchema(Object.fromEntries(Object.keys(aspects).map(aspect => [aspect, score]))) }), 'review-policy');
40
- node('review-policy', 'Review acceptance policy', 'condition', { expr: ['$outputs.reviewer.structured.approved == true', '$outputs.reviewer.structured.issues.length == 0', '$outputs.reviewer.structured.score >= 70', ...Object.entries(aspects).map(([aspect, threshold]) => `$outputs.reviewer.structured.aspects.${aspect} >= ${threshold}`)].join(' && ') }, { true: 'approve', false: 'fixer' });
41
- turn('fixer', 'Fixer', 'correct', `Correct only failures in change ${change}, using the bound apply skill and preserving approved artifacts. Report summary and incomplete tasks. Host verification: {{outputs.verify}}. Prior execution: {{history.text}}`, implementation, 'tasks');
40
+ node('verify', 'Verify candidate', 'verify', { commands: 'configured', additionalCommandsFrom: 'architect' }, { pass: 'reviewer', fail: 'correction-context', failed: 'failed' });
41
+ turn('reviewer', 'Reviewer', 'assess', `Review change ${change} read-only against all approved artifacts and frozen ticket acceptance criteria. Inspect real code and the host verification evidence: {{outputs.verify}}. Return approved, summary, issues, score (0-100), and aspects. Acceptance policy: ${reviewPolicy}. For any rejection or below-threshold score, explain the concrete unmet requirement and actionable repair in summary and issues; passing commands do not prove every acceptance obligation. Prior execution: {{history.text}}`, objectSchema({ approved: { type: 'boolean' }, summary: string, issues: strings, score, aspects: objectSchema(Object.fromEntries(Object.keys(aspects).map(aspect => [aspect, score]))) }), 'review-policy');
42
+ node('review-policy', 'Review acceptance policy', 'condition', { expr: ['$outputs.reviewer.structured.approved == true', '$outputs.reviewer.structured.issues.length == 0', '$outputs.reviewer.structured.score >= 70', ...Object.entries(aspects).map(([aspect, threshold]) => `$outputs.reviewer.structured.aspects.${aspect} >= ${threshold}`)].join(' && ') }, { true: 'approve', false: 'correction-context' });
43
+ node('correction-context', 'Select current review findings', 'condition', { expr: '$outputs.verify.valid == true && exists($outputs.reviewer.candidateHash) && $outputs.reviewer.candidateHash == $outputs.verify.candidateHash' }, { true: 'capture-review', false: 'clear-review' });
44
+ node('capture-review', 'Capture current review findings', 'assign', { set: { latestReview: '{{outputs.reviewer.structured}}' } }, { next: 'correction-budget', failed: 'failed' });
45
+ node('clear-review', 'Clear outdated review findings', 'assign', { set: { latestReview: 'No reviewer findings for the current verified candidate. Diagnose the current host verification failure first.' } }, { next: 'correction-budget', failed: 'failed' });
46
+ node('correction-budget', 'Check automatic correction budget', 'condition', { expr: '$vars.correctionAttempts < $vars.correctionLimit' }, { true: 'begin-correction', false: 'correction-exhausted' });
47
+ node('begin-correction', 'Count automatic correction attempt', 'assign', { increment: { correctionAttempts: 1 } }, { next: 'fixer', failed: 'failed' });
48
+ turn('fixer', 'Fixer', 'correct', `Correct the reported implementation, test or review failure for change ${change} using the bound apply skill and preserving approved artifacts. Read failureSummary, command, cwd, exitCode and evidenceId first; use read_verification_evidence for truncated evidence. If Host verification is valid, repair the concrete rejected review obligation or below-threshold aspect instead of only rerunning green commands. Acceptance policy: ${reviewPolicy}. Reproduce the exact focused failure, inspect the assertion and tested code, then apply the smallest repair that preserves the required behavior. An unchanged filename does not prove an unrelated failure. A mandatory test inside the admitted workspace may receive a proven formatting/compatibility repair while retaining every required guard. Report diagnosis, focused commands with original exit codes, repair and unresolved failures in summary and incomplete. If the cause cannot be repaired within scope, explain the evidence and required action; any unchanged correction stops this run even when host checks pass. Do not make unrelated edits merely to change the candidate hash. Host verification: {{outputs.verify}}. Latest review findings for this candidate: {{run.latestReview}}. Prior execution: {{history.text}}`, implementation, 'correction-progress');
49
+ node('correction-progress', 'Check correction progress', 'condition', { expr: 'exists($outputs.fixer.candidateHash) && $outputs.fixer.candidateHash != null && $outputs.fixer.candidateHash != $outputs.verify.candidateHash' }, { true: 'tasks', false: 'correction-stalled' });
42
50
  node('approve', 'Approve archive', 'approval', { reason: `Approve archiving the reviewed and verified candidate for change ${change}.`, bindCandidate: true, enabled: agents.approvalBeforeArchive === true }, { next: 'approval-policy' });
43
51
  node('approval-policy', 'Approval decision', 'condition', { expr: '$outputs.approve.response.approved == true' }, { true: 'archive', false: 'failed' });
44
52
  node('archive', 'Archive OpenSpec', 'openspec-archive', { change, allowArchived: true, requiresVerified: true, reviewedCandidate: '{{outputs.reviewer.candidateHash}}', approvedCandidate: '{{outputs.approve.candidateHash}}' }, { next: 'verify-archive', failed: 'verify' });
45
53
  node('verify-archive', 'Verify archived candidate', 'verify', { commands: 'configured', additionalCommandsFrom: 'architect' }, { pass: 'done', fail: 'failed', failed: 'failed' });
46
- nodes.push({ id: 'done', type: 'end', position: { x: 0, y: nodes.length * 140 }, data: { outcome: 'success', requiresVerified: true } }, { id: 'failed', type: 'end', position: { x: 360, y: nodes.length * 140 }, data: { outcome: 'failure' } });
54
+ nodes.push({ id: 'done', type: 'end', position: { x: 0, y: nodes.length * 140 }, data: { outcome: 'success', requiresVerified: true } }, { id: 'failed', type: 'end', position: { x: 360, y: nodes.length * 140 }, data: { outcome: 'failure' } }, { id: 'correction-stalled', type: 'end', position: { x: 720, y: nodes.length * 140 }, data: { outcome: 'failure', reason: `Automatic correction made no candidate changes. Host verification is valid: {{outputs.verify.valid}}. Acceptance policy: ${reviewPolicy}. Latest review findings: {{run.latestReview}}. Fixer diagnosis: {{outputs.fixer.structured.summary}}` } }, { id: 'correction-exhausted', type: 'end', position: { x: 1080, y: nodes.length * 140 }, data: { outcome: 'failure', reason: `Automatic correction limit reached ({{run.correctionAttempts}}/{{run.correctionLimit}}). Acceptance policy: ${reviewPolicy}. Latest review findings: {{run.latestReview}}. Fixer diagnosis: {{outputs.fixer.structured.summary}}. Host verification: {{outputs.verify}}` } });
47
55
  edges.unshift({ id: 'e-start', source: 'start', target: 'architect' });
48
56
  return { nodes, edges, config: { maxIterations: 12, maxTransitions: 200, timeoutMinutes: 0, aiStepTimeoutMinutes: 0, change: 'new', journal: 'ledger-only', ticketScope: 'all', agents, reviewerStepId: 'reviewer' } };
49
57
  }
@@ -145,29 +145,37 @@ const CHANGE_ID_RE = /openspec[\\/]+changes[\\/]+(?!archive(?:[\\/]|$))([A-Za-z0
145
145
  function resolveRunVars(text, vars) {
146
146
  return text.replace(RUN_TOKEN_RE, (_m, key) => vars[key] ?? '');
147
147
  }
148
- const CHANGE_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,99}$/i;
148
+ const CHANGE_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
149
149
  /** The authoritative OpenSpec target for a launch.
150
- * Addenda/delivery deltas get a run-specific name; ordinary launches use the
150
+ * Addenda/delivery deltas use durable content/lineage identity; ordinary launches use the
151
151
  * follow-up's declared name, else the spec's `openspecChangeName`. */
152
152
  function seedChangeId(req) {
153
- if (req.addenda?.ids.length) {
154
- // A new generation never picks an old/archived proposal, even when the
155
- // ticket metadata still names it. Resuming the same run keeps its target.
156
- const hash = (0, crypto_1.createHash)('sha256').update(JSON.stringify([req.runId, req.addenda.ids, req.addenda.briefing])).digest('hex').slice(0, 20);
157
- return { id: `spec-addenda-${hash}`, source: 'addenda' };
158
- }
159
- if (req.constants?.REVISION_REQUEST) {
160
- const hash = (0, crypto_1.createHash)('sha256').update(JSON.stringify([req.runId, req.constants.REVISION_REQUEST])).digest('hex').slice(0, 20);
161
- return { id: `delivery-change-${hash}`, source: 'revision' };
162
- }
153
+ const valid = (value) => value.length <= 64 && CHANGE_NAME_RE.test(value);
163
154
  const fromFollowUp = typeof req.followUp?.openspecChangeName === 'string' ? req.followUp.openspecChangeName.trim() : '';
164
- if (fromFollowUp && CHANGE_NAME_RE.test(fromFollowUp))
165
- return { id: fromFollowUp, source: 'followUp' };
166
155
  const direct = typeof req.spec?.openspecChangeName === 'string' ? req.spec.openspecChangeName.trim() : '';
167
156
  const fromMetadata = typeof req.spec?.metadata?.openspecChangeName === 'string' ? req.spec.metadata.openspecChangeName.trim() : '';
168
- const fromSpec = direct || fromMetadata;
169
- if (fromSpec && CHANGE_NAME_RE.test(fromSpec))
170
- return { id: fromSpec, source: 'spec' };
157
+ const declared = valid(fromFollowUp) ? fromFollowUp : valid(direct || fromMetadata) ? direct || fromMetadata : '';
158
+ const source = declared === fromFollowUp ? 'followUp' : 'spec';
159
+ if (req.addenda?.ids.length || req.constants?.REVISION_REQUEST) {
160
+ const state = declared ? openspecChangeState(req.cwd, declared) : 'missing';
161
+ // A pending implementation's addenda refine the same change. Only work
162
+ // already delivered/archived needs a separate delta proposal.
163
+ if (declared && (state === 'active' || (state === 'missing' && !req.constants?.REVISION_REQUEST && req.spec?.status !== 'on_review')))
164
+ return { id: declared, source };
165
+ const hash = (0, crypto_1.createHash)('sha256').update(JSON.stringify([
166
+ req.constants?.REVISION_OF_DELIVERY_ID ?? null,
167
+ req.constants?.REVISION_NUMBER ?? null,
168
+ req.constants?.REVISION_REQUEST ?? null,
169
+ req.addenda ? [...req.addenda.ids].sort() : [], req.addenda?.briefing ?? null,
170
+ ])).digest('hex').slice(0, 12);
171
+ const revisionNumber = req.constants?.REVISION_NUMBER;
172
+ const kind = req.addenda?.ids.length ? 'addenda' : 'revision';
173
+ const suffix = req.constants?.REVISION_REQUEST ? `rev${revisionNumber && /^[1-9]\d{0,3}$/.test(revisionNumber) ? revisionNumber : '1'}-${hash}` : `addenda-${hash}`;
174
+ const stem = declared || (req.ticketId && req.ticketId > 0 ? `ticket-${req.ticketId}` : 'spec');
175
+ return { id: `${stem.slice(0, 63 - suffix.length).replace(/-+$/, '')}-${suffix}`, source: kind };
176
+ }
177
+ if (declared)
178
+ return { id: declared, source };
171
179
  return undefined;
172
180
  }
173
181
  /** Where an OpenSpec change currently lives under `cwd`: an ACTIVE
@@ -854,6 +862,8 @@ class LoopRunManager {
854
862
  });
855
863
  if (definitionEngine)
856
864
  (0, loop_runs_store_1.saveDefinitionRun)(this.db, runId, { request: req, source: 'definition' });
865
+ if (req.launchConfig)
866
+ this.db.prepare('UPDATE loop_runs SET launch_config_json = ? WHERE id = ?').run(JSON.stringify(req.launchConfig), runId);
857
867
  if (req.executionManifest)
858
868
  this.db.prepare('UPDATE loop_runs SET execution_manifest = ? WHERE id = ?').run(JSON.stringify(req.executionManifest), runId);
859
869
  (0, db_1.createJob)(this.db, {
@@ -85,29 +85,39 @@ function createClaudeReader(options = {}) {
85
85
  const oauth = (0, domain_1.record)(data.claudeAiOauth);
86
86
  if (typeof oauth.accessToken !== 'string' || !oauth.accessToken)
87
87
  return { availability: 'unsupported-auth', windows: [], plan: null, source: null };
88
- const response = await (options.request ?? fetch)('https://api.anthropic.com/api/oauth/usage', {
89
- headers: { Authorization: `Bearer ${oauth.accessToken}`, 'anthropic-beta': 'oauth-2025-04-20', 'User-Agent': 'claude-code/2.1.0' },
90
- signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]), redirect: 'error',
91
- });
92
- if (!response.ok) {
93
- await response.body?.cancel().catch(() => { });
94
- if (response.status === 401)
95
- throw new errors_1.UsageError('signed-out');
96
- if (response.status === 403)
97
- throw new errors_1.UsageError('permission-denied');
98
- if (response.status === 429) {
99
- const header = response.headers.get('retry-after');
100
- const seconds = header !== null && header.trim() !== '' ? Number(header) : NaN;
101
- const date = header ? Date.parse(header) : NaN;
102
- const retry = Number.isFinite(seconds) ? seconds * 1000 : Number.isFinite(date) ? date - Date.now() : 60_000;
103
- throw new errors_1.UsageError('rate-limited', true, Math.max(30_000, Math.min(24 * 60 * 60_000, retry)));
88
+ async function query(route) {
89
+ const response = await (options.request ?? fetch)(`https://api.anthropic.com/api/oauth/${route}`, {
90
+ headers: { Authorization: `Bearer ${oauth.accessToken}`, 'anthropic-beta': 'oauth-2025-04-20', 'User-Agent': 'claude-code/2.1.0' },
91
+ signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]), redirect: 'error',
92
+ });
93
+ if (!response.ok) {
94
+ await response.body?.cancel().catch(() => { });
95
+ if (response.status === 401)
96
+ throw new errors_1.UsageError('signed-out');
97
+ if (response.status === 403)
98
+ throw new errors_1.UsageError('permission-denied');
99
+ if (response.status === 429) {
100
+ const header = response.headers.get('retry-after');
101
+ const seconds = header !== null && header.trim() !== '' ? Number(header) : NaN;
102
+ const date = header ? Date.parse(header) : NaN;
103
+ const retry = Number.isFinite(seconds) ? seconds * 1000 : Number.isFinite(date) ? date - Date.now() : 60_000;
104
+ throw new errors_1.UsageError('rate-limited', true, Math.max(30_000, Math.min(24 * 60 * 60_000, retry)));
105
+ }
106
+ throw new errors_1.UsageError('provider-error', response.status >= 500);
104
107
  }
105
- throw new errors_1.UsageError('provider-error', response.status >= 500);
108
+ return (0, local_auth_1.limitedJson)(response);
106
109
  }
107
- const payload = await (0, local_auth_1.limitedJson)(response), windows = (0, domain_1.normalizeClaude)(payload);
108
- if (!windows.length)
110
+ const payload = await query('usage'), windows = (0, domain_1.normalizeClaude)(payload);
111
+ let plan = (0, domain_1.claudePlan)(oauth.subscriptionType) ?? (0, domain_1.claudePlan)(oauth.rateLimitTier);
112
+ const candidate = (0, domain_1.normalizeClaudeSpend)(payload, 'enterprise', (options.now ?? Date.now)());
113
+ if (!plan && candidate) {
114
+ const profile = (0, domain_1.record)(await query('profile')), organization = (0, domain_1.record)(profile.organization);
115
+ plan = (0, domain_1.claudePlan)(organization.organization_type) ?? (0, domain_1.claudePlan)(organization.rate_limit_tier);
116
+ }
117
+ const spend = plan === 'enterprise' ? candidate : null;
118
+ if (!windows.length && !spend)
109
119
  throw new errors_1.UsageError('usage-unavailable');
110
- return { availability: 'available', windows, plan: null, source: 'oauth' };
120
+ return { availability: 'available', windows: spend ? [] : windows, spend, plan, source: 'oauth' };
111
121
  },
112
122
  };
113
123
  }
@@ -5,7 +5,8 @@ exports.backoff = backoff;
5
5
  function withFreshness(row, now) {
6
6
  if (!row.observedAt)
7
7
  return { ...row, freshness: 'unknown' };
8
- const expired = now - Date.parse(row.observedAt) >= 300_000 || row.windows.some(w => w.resetsAt && Date.parse(w.resetsAt) <= now);
8
+ const expired = now - Date.parse(row.observedAt) >= 300_000 || row.windows.some(w => w.resetsAt && Date.parse(w.resetsAt) <= now)
9
+ || !!row.spend?.resetsAt && Date.parse(row.spend.resetsAt) <= now;
9
10
  return { ...row, freshness: expired || row.refreshState === 'error' ? 'stale' : 'fresh' };
10
11
  }
11
12
  function backoff(failures) { return Math.min(300_000, 30_000 * 2 ** Math.min(4, Math.max(0, failures - 1))); }