orch-code 0.1.1

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 (116) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +624 -0
  4. package/cmd/apply.go +111 -0
  5. package/cmd/auth.go +393 -0
  6. package/cmd/auth_test.go +100 -0
  7. package/cmd/diff.go +57 -0
  8. package/cmd/doctor.go +149 -0
  9. package/cmd/explain.go +192 -0
  10. package/cmd/explain_test.go +62 -0
  11. package/cmd/init.go +100 -0
  12. package/cmd/interactive.go +1372 -0
  13. package/cmd/interactive_input.go +45 -0
  14. package/cmd/interactive_input_test.go +55 -0
  15. package/cmd/logs.go +72 -0
  16. package/cmd/model.go +84 -0
  17. package/cmd/plan.go +149 -0
  18. package/cmd/provider.go +189 -0
  19. package/cmd/provider_model_doctor_test.go +91 -0
  20. package/cmd/root.go +67 -0
  21. package/cmd/run.go +123 -0
  22. package/cmd/run_engine.go +208 -0
  23. package/cmd/run_engine_test.go +30 -0
  24. package/cmd/session.go +589 -0
  25. package/cmd/session_helpers.go +54 -0
  26. package/cmd/session_integration_test.go +30 -0
  27. package/cmd/session_list_current_test.go +87 -0
  28. package/cmd/session_messages_test.go +163 -0
  29. package/cmd/session_runs_test.go +68 -0
  30. package/cmd/sprint1_integration_test.go +119 -0
  31. package/cmd/stats.go +173 -0
  32. package/cmd/stats_test.go +71 -0
  33. package/cmd/version.go +4 -0
  34. package/go.mod +45 -0
  35. package/go.sum +108 -0
  36. package/internal/agents/agent.go +31 -0
  37. package/internal/agents/coder.go +167 -0
  38. package/internal/agents/planner.go +155 -0
  39. package/internal/agents/reviewer.go +118 -0
  40. package/internal/agents/runtime.go +25 -0
  41. package/internal/agents/runtime_test.go +77 -0
  42. package/internal/auth/account.go +78 -0
  43. package/internal/auth/oauth.go +523 -0
  44. package/internal/auth/store.go +287 -0
  45. package/internal/confidence/policy.go +174 -0
  46. package/internal/confidence/policy_test.go +71 -0
  47. package/internal/confidence/scorer.go +253 -0
  48. package/internal/confidence/scorer_test.go +83 -0
  49. package/internal/config/config.go +331 -0
  50. package/internal/config/config_defaults_test.go +138 -0
  51. package/internal/execution/contract_builder.go +160 -0
  52. package/internal/execution/contract_builder_test.go +68 -0
  53. package/internal/execution/plan_compliance.go +161 -0
  54. package/internal/execution/plan_compliance_test.go +71 -0
  55. package/internal/execution/retry_directive.go +132 -0
  56. package/internal/execution/scope_guard.go +69 -0
  57. package/internal/logger/logger.go +120 -0
  58. package/internal/models/contracts_test.go +100 -0
  59. package/internal/models/models.go +269 -0
  60. package/internal/orchestrator/orchestrator.go +701 -0
  61. package/internal/orchestrator/orchestrator_retry_test.go +135 -0
  62. package/internal/orchestrator/review_engine_test.go +50 -0
  63. package/internal/orchestrator/state.go +42 -0
  64. package/internal/orchestrator/test_classifier_test.go +68 -0
  65. package/internal/patch/applier.go +131 -0
  66. package/internal/patch/applier_test.go +25 -0
  67. package/internal/patch/parser.go +89 -0
  68. package/internal/patch/patch.go +60 -0
  69. package/internal/patch/summary.go +30 -0
  70. package/internal/patch/validator.go +104 -0
  71. package/internal/planning/normalizer.go +416 -0
  72. package/internal/planning/normalizer_test.go +64 -0
  73. package/internal/providers/errors.go +35 -0
  74. package/internal/providers/openai/client.go +498 -0
  75. package/internal/providers/openai/client_test.go +187 -0
  76. package/internal/providers/provider.go +47 -0
  77. package/internal/providers/registry.go +32 -0
  78. package/internal/providers/registry_test.go +57 -0
  79. package/internal/providers/router.go +52 -0
  80. package/internal/providers/state.go +114 -0
  81. package/internal/providers/state_test.go +64 -0
  82. package/internal/repo/analyzer.go +188 -0
  83. package/internal/repo/context.go +83 -0
  84. package/internal/review/engine.go +267 -0
  85. package/internal/review/engine_test.go +103 -0
  86. package/internal/runstore/store.go +137 -0
  87. package/internal/runstore/store_test.go +59 -0
  88. package/internal/runtime/lock.go +150 -0
  89. package/internal/runtime/lock_test.go +57 -0
  90. package/internal/session/compaction.go +260 -0
  91. package/internal/session/compaction_test.go +36 -0
  92. package/internal/session/service.go +117 -0
  93. package/internal/session/service_test.go +113 -0
  94. package/internal/storage/storage.go +1498 -0
  95. package/internal/storage/storage_test.go +413 -0
  96. package/internal/testing/classifier.go +80 -0
  97. package/internal/testing/classifier_test.go +36 -0
  98. package/internal/tools/command.go +160 -0
  99. package/internal/tools/command_test.go +56 -0
  100. package/internal/tools/file.go +111 -0
  101. package/internal/tools/git.go +77 -0
  102. package/internal/tools/invalid_params_test.go +36 -0
  103. package/internal/tools/policy.go +98 -0
  104. package/internal/tools/policy_test.go +36 -0
  105. package/internal/tools/registry_test.go +52 -0
  106. package/internal/tools/result.go +30 -0
  107. package/internal/tools/search.go +86 -0
  108. package/internal/tools/tool.go +94 -0
  109. package/main.go +9 -0
  110. package/npm/orch.js +25 -0
  111. package/package.json +41 -0
  112. package/scripts/changelog.js +20 -0
  113. package/scripts/check-release-version.js +21 -0
  114. package/scripts/lib/release-utils.js +223 -0
  115. package/scripts/postinstall.js +157 -0
  116. package/scripts/release.js +52 -0
package/cmd/session.go ADDED
@@ -0,0 +1,589 @@
1
+ package cmd
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+ "os"
8
+ "strings"
9
+ "time"
10
+
11
+ "github.com/furkanbeydemir/orch/internal/session"
12
+ "github.com/furkanbeydemir/orch/internal/storage"
13
+ "github.com/spf13/cobra"
14
+ )
15
+
16
+ var sessionCmd = &cobra.Command{
17
+ Use: "session",
18
+ Short: "Manage run sessions",
19
+ }
20
+
21
+ var sessionListCmd = &cobra.Command{
22
+ Use: "list",
23
+ Short: "List sessions",
24
+ RunE: runSessionList,
25
+ }
26
+
27
+ var sessionCreateCmd = &cobra.Command{
28
+ Use: "create [name]",
29
+ Short: "Create a session",
30
+ Args: cobra.ExactArgs(1),
31
+ RunE: runSessionCreate,
32
+ }
33
+
34
+ var sessionRunsCmd = &cobra.Command{
35
+ Use: "runs [name-or-id]",
36
+ Short: "List recent runs for a session",
37
+ Args: cobra.MaximumNArgs(1),
38
+ RunE: runSessionRuns,
39
+ }
40
+
41
+ var sessionMessagesCmd = &cobra.Command{
42
+ Use: "messages [name-or-id]",
43
+ Short: "List recent messages for a session",
44
+ Args: cobra.MaximumNArgs(1),
45
+ RunE: runSessionMessages,
46
+ }
47
+
48
+ var (
49
+ sessionCreateWorktree string
50
+ sessionRunsLimit int
51
+ sessionRunsStatus string
52
+ sessionRunsContains string
53
+ sessionRunsJSON bool
54
+ sessionMessagesLimit int
55
+ sessionMessagesJSON bool
56
+ sessionListJSON bool
57
+ sessionCurrentJSON bool
58
+ )
59
+
60
+ var sessionSelectCmd = &cobra.Command{
61
+ Use: "select [name-or-id]",
62
+ Short: "Select active session",
63
+ Args: cobra.ExactArgs(1),
64
+ RunE: runSessionSelect,
65
+ }
66
+
67
+ var sessionCloseCmd = &cobra.Command{
68
+ Use: "close [name-or-id]",
69
+ Short: "Close a session",
70
+ Args: cobra.ExactArgs(1),
71
+ RunE: runSessionClose,
72
+ }
73
+
74
+ var sessionCurrentCmd = &cobra.Command{
75
+ Use: "current",
76
+ Short: "Show active session",
77
+ RunE: runSessionCurrent,
78
+ }
79
+
80
+ func init() {
81
+ rootCmd.AddCommand(sessionCmd)
82
+ sessionCmd.AddCommand(sessionListCmd)
83
+ sessionCmd.AddCommand(sessionCreateCmd)
84
+ sessionCmd.AddCommand(sessionSelectCmd)
85
+ sessionCmd.AddCommand(sessionCloseCmd)
86
+ sessionCmd.AddCommand(sessionCurrentCmd)
87
+ sessionCmd.AddCommand(sessionRunsCmd)
88
+ sessionCmd.AddCommand(sessionMessagesCmd)
89
+
90
+ sessionCreateCmd.Flags().StringVar(&sessionCreateWorktree, "worktree-path", "", "Optional worktree path for session execution")
91
+ sessionListCmd.Flags().BoolVar(&sessionListJSON, "json", false, "Output sessions as JSON")
92
+ sessionCurrentCmd.Flags().BoolVar(&sessionCurrentJSON, "json", false, "Output active session as JSON")
93
+ sessionRunsCmd.Flags().IntVar(&sessionRunsLimit, "limit", 20, "Maximum number of runs to show")
94
+ sessionRunsCmd.Flags().StringVar(&sessionRunsStatus, "status", "", "Filter runs by status")
95
+ sessionRunsCmd.Flags().StringVar(&sessionRunsContains, "contains", "", "Filter runs by task text")
96
+ sessionRunsCmd.Flags().BoolVar(&sessionRunsJSON, "json", false, "Output runs as JSON")
97
+ sessionMessagesCmd.Flags().IntVar(&sessionMessagesLimit, "limit", 40, "Maximum number of messages to show")
98
+ sessionMessagesCmd.Flags().BoolVar(&sessionMessagesJSON, "json", false, "Output messages as JSON")
99
+ }
100
+
101
+ func runSessionList(cmd *cobra.Command, args []string) error {
102
+ cwd, err := os.Getwd()
103
+ if err != nil {
104
+ return fmt.Errorf("failed to get working directory: %w", err)
105
+ }
106
+
107
+ ctx, err := loadSessionContext(cwd)
108
+ if err != nil {
109
+ return err
110
+ }
111
+ defer ctx.Store.Close()
112
+
113
+ sessions, err := ctx.Store.ListSessions(ctx.ProjectID)
114
+ if err != nil {
115
+ return err
116
+ }
117
+
118
+ if len(sessions) == 0 {
119
+ if sessionListJSON {
120
+ fmt.Println("[]")
121
+ return nil
122
+ }
123
+ fmt.Println("No sessions found.")
124
+ return nil
125
+ }
126
+
127
+ if sessionListJSON {
128
+ type jsonSession struct {
129
+ ID string `json:"id"`
130
+ ProjectID string `json:"project_id"`
131
+ Name string `json:"name"`
132
+ Status string `json:"status"`
133
+ Worktree string `json:"worktree,omitempty"`
134
+ CreatedAt time.Time `json:"created_at"`
135
+ ClosedAt *time.Time `json:"closed_at,omitempty"`
136
+ IsActive bool `json:"is_active"`
137
+ }
138
+ payload := make([]jsonSession, 0, len(sessions))
139
+ for _, s := range sessions {
140
+ payload = append(payload, jsonSession{
141
+ ID: s.ID,
142
+ ProjectID: s.ProjectID,
143
+ Name: s.Name,
144
+ Status: s.Status,
145
+ Worktree: s.Worktree,
146
+ CreatedAt: s.CreatedAt,
147
+ ClosedAt: s.ClosedAt,
148
+ IsActive: s.IsActive,
149
+ })
150
+ }
151
+ encoded, marshalErr := json.MarshalIndent(payload, "", " ")
152
+ if marshalErr != nil {
153
+ return fmt.Errorf("failed to encode sessions json: %w", marshalErr)
154
+ }
155
+ fmt.Println(string(encoded))
156
+ return nil
157
+ }
158
+
159
+ for _, s := range sessions {
160
+ marker := " "
161
+ if s.IsActive {
162
+ marker = "*"
163
+ }
164
+ if s.Worktree != "" {
165
+ fmt.Printf("%s %s (%s) status=%s worktree=%s\n", marker, s.Name, s.ID, s.Status, s.Worktree)
166
+ } else {
167
+ fmt.Printf("%s %s (%s) status=%s\n", marker, s.Name, s.ID, s.Status)
168
+ }
169
+ }
170
+
171
+ return nil
172
+ }
173
+
174
+ func runSessionCreate(cmd *cobra.Command, args []string) error {
175
+ cwd, err := os.Getwd()
176
+ if err != nil {
177
+ return fmt.Errorf("failed to get working directory: %w", err)
178
+ }
179
+
180
+ ctx, err := loadSessionContext(cwd)
181
+ if err != nil {
182
+ return err
183
+ }
184
+ defer ctx.Store.Close()
185
+
186
+ created, err := ctx.Store.CreateSessionWithWorktree(ctx.ProjectID, args[0], sessionCreateWorktree)
187
+ if err != nil {
188
+ if errors.Is(err, storage.ErrNameConflict) {
189
+ return fmt.Errorf("session already exists: %s", args[0])
190
+ }
191
+ return err
192
+ }
193
+
194
+ if err := ctx.Store.SetActiveSession(ctx.ProjectID, created.ID); err != nil {
195
+ return err
196
+ }
197
+
198
+ if created.Worktree != "" {
199
+ fmt.Printf("Created and selected session: %s (%s) worktree=%s\n", created.Name, created.ID, created.Worktree)
200
+ } else {
201
+ fmt.Printf("Created and selected session: %s (%s)\n", created.Name, created.ID)
202
+ }
203
+ return nil
204
+ }
205
+
206
+ func runSessionSelect(cmd *cobra.Command, args []string) error {
207
+ cwd, err := os.Getwd()
208
+ if err != nil {
209
+ return fmt.Errorf("failed to get working directory: %w", err)
210
+ }
211
+
212
+ ctx, err := loadSessionContext(cwd)
213
+ if err != nil {
214
+ return err
215
+ }
216
+ defer ctx.Store.Close()
217
+
218
+ selected, err := ctx.Store.SelectSession(ctx.ProjectID, args[0])
219
+ if err != nil {
220
+ switch {
221
+ case errors.Is(err, storage.ErrSessionNotFound):
222
+ return fmt.Errorf("session not found: %s", args[0])
223
+ case errors.Is(err, storage.ErrSessionClosed):
224
+ return fmt.Errorf("cannot select closed session: %s", args[0])
225
+ default:
226
+ return err
227
+ }
228
+ }
229
+
230
+ fmt.Printf("Active session: %s (%s)\n", selected.Name, selected.ID)
231
+ return nil
232
+ }
233
+
234
+ func runSessionClose(cmd *cobra.Command, args []string) error {
235
+ cwd, err := os.Getwd()
236
+ if err != nil {
237
+ return fmt.Errorf("failed to get working directory: %w", err)
238
+ }
239
+
240
+ ctx, err := loadSessionContext(cwd)
241
+ if err != nil {
242
+ return err
243
+ }
244
+ defer ctx.Store.Close()
245
+
246
+ if err := ctx.Store.CloseSession(ctx.ProjectID, args[0]); err != nil {
247
+ if errors.Is(err, storage.ErrSessionNotFound) {
248
+ return fmt.Errorf("session not found: %s", args[0])
249
+ }
250
+ return err
251
+ }
252
+
253
+ fmt.Printf("Closed session: %s\n", args[0])
254
+ return nil
255
+ }
256
+
257
+ func runSessionCurrent(cmd *cobra.Command, args []string) error {
258
+ cwd, err := os.Getwd()
259
+ if err != nil {
260
+ return fmt.Errorf("failed to get working directory: %w", err)
261
+ }
262
+
263
+ ctx, err := loadSessionContext(cwd)
264
+ if err != nil {
265
+ return err
266
+ }
267
+ defer ctx.Store.Close()
268
+
269
+ if sessionCurrentJSON {
270
+ payload := map[string]any{
271
+ "id": ctx.Session.ID,
272
+ "project_id": ctx.ProjectID,
273
+ "name": ctx.Session.Name,
274
+ "status": ctx.Session.Status,
275
+ "worktree": ctx.Session.Worktree,
276
+ "is_active": true,
277
+ }
278
+ encoded, err := json.MarshalIndent(payload, "", " ")
279
+ if err != nil {
280
+ return fmt.Errorf("failed to encode current session json: %w", err)
281
+ }
282
+ fmt.Println(string(encoded))
283
+ return nil
284
+ }
285
+
286
+ fmt.Printf("Active session: %s (%s)\n", ctx.Session.Name, ctx.Session.ID)
287
+ if ctx.Session.Worktree != "" {
288
+ fmt.Printf("Worktree: %s\n", ctx.Session.Worktree)
289
+ }
290
+ return nil
291
+ }
292
+
293
+ func runSessionRuns(cmd *cobra.Command, args []string) error {
294
+ cwd, err := os.Getwd()
295
+ if err != nil {
296
+ return fmt.Errorf("failed to get working directory: %w", err)
297
+ }
298
+
299
+ ctx, err := loadSessionContext(cwd)
300
+ if err != nil {
301
+ return err
302
+ }
303
+ defer ctx.Store.Close()
304
+
305
+ target := ctx.Session
306
+ if len(args) == 1 {
307
+ target, err = ctx.Store.GetSession(ctx.ProjectID, args[0])
308
+ if err != nil {
309
+ switch {
310
+ case errors.Is(err, storage.ErrSessionNotFound):
311
+ return fmt.Errorf("session not found: %s", args[0])
312
+ default:
313
+ return err
314
+ }
315
+ }
316
+ }
317
+
318
+ runs, err := ctx.Store.ListRunsBySessionFiltered(target.ID, sessionRunsLimit, sessionRunsStatus, sessionRunsContains)
319
+ if err != nil {
320
+ return err
321
+ }
322
+
323
+ if len(runs) == 0 {
324
+ if sessionRunsJSON {
325
+ fmt.Println("[]")
326
+ return nil
327
+ }
328
+ fmt.Printf("No runs found for session %s (%s).\n", target.Name, target.ID)
329
+ return nil
330
+ }
331
+
332
+ if sessionRunsJSON {
333
+ type jsonRun struct {
334
+ ID string `json:"id"`
335
+ SessionID string `json:"session_id"`
336
+ Status string `json:"status"`
337
+ Task string `json:"task"`
338
+ StartedAt time.Time `json:"started_at"`
339
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
340
+ Error string `json:"error,omitempty"`
341
+ }
342
+ payload := make([]jsonRun, 0, len(runs))
343
+ for _, run := range runs {
344
+ payload = append(payload, jsonRun{
345
+ ID: run.ID,
346
+ SessionID: run.SessionID,
347
+ Status: run.Status,
348
+ Task: run.Task,
349
+ StartedAt: run.StartedAt,
350
+ CompletedAt: run.CompletedAt,
351
+ Error: run.Error,
352
+ })
353
+ }
354
+ encoded, marshalErr := json.MarshalIndent(payload, "", " ")
355
+ if marshalErr != nil {
356
+ return fmt.Errorf("failed to encode runs json: %w", marshalErr)
357
+ }
358
+ fmt.Println(string(encoded))
359
+ return nil
360
+ }
361
+
362
+ for _, run := range runs {
363
+ line := fmt.Sprintf("- %s status=%s task=%q started=%s", run.ID, run.Status, run.Task, run.StartedAt.Format(time.RFC3339))
364
+ if run.CompletedAt != nil {
365
+ line += fmt.Sprintf(" completed=%s", run.CompletedAt.Format(time.RFC3339))
366
+ }
367
+ if run.Error != "" {
368
+ line += fmt.Sprintf(" error=%q", run.Error)
369
+ }
370
+ fmt.Println(line)
371
+ }
372
+
373
+ return nil
374
+ }
375
+
376
+ func runSessionMessages(cmd *cobra.Command, args []string) error {
377
+ cwd, err := os.Getwd()
378
+ if err != nil {
379
+ return fmt.Errorf("failed to get working directory: %w", err)
380
+ }
381
+
382
+ ctx, err := loadSessionContext(cwd)
383
+ if err != nil {
384
+ return err
385
+ }
386
+ defer ctx.Store.Close()
387
+
388
+ target := ctx.Session
389
+ if len(args) == 1 {
390
+ target, err = ctx.Store.GetSession(ctx.ProjectID, args[0])
391
+ if err != nil {
392
+ switch {
393
+ case errors.Is(err, storage.ErrSessionNotFound):
394
+ return fmt.Errorf("session not found: %s", args[0])
395
+ default:
396
+ return err
397
+ }
398
+ }
399
+ }
400
+
401
+ svc := session.NewService(ctx.Store)
402
+ messages, err := svc.ListMessagesWithParts(target.ID, sessionMessagesLimit)
403
+ if err != nil {
404
+ return err
405
+ }
406
+
407
+ if len(messages) == 0 {
408
+ if sessionMessagesJSON {
409
+ fmt.Println("[]")
410
+ return nil
411
+ }
412
+ fmt.Printf("No messages found for session %s (%s).\n", target.Name, target.ID)
413
+ return nil
414
+ }
415
+
416
+ if sessionMessagesJSON {
417
+ type jsonPart struct {
418
+ ID string `json:"id"`
419
+ Type string `json:"type"`
420
+ Compacted bool `json:"compacted"`
421
+ Rendered string `json:"rendered"`
422
+ Payload string `json:"payload"`
423
+ }
424
+ type jsonMessage struct {
425
+ ID string `json:"id"`
426
+ SessionID string `json:"session_id"`
427
+ Role string `json:"role"`
428
+ ParentID string `json:"parent_id,omitempty"`
429
+ ProviderID string `json:"provider_id,omitempty"`
430
+ ModelID string `json:"model_id,omitempty"`
431
+ FinishReason string `json:"finish_reason,omitempty"`
432
+ Error string `json:"error,omitempty"`
433
+ CreatedAt time.Time `json:"created_at"`
434
+ Parts []jsonPart `json:"parts"`
435
+ }
436
+
437
+ payload := make([]jsonMessage, 0, len(messages))
438
+ for _, item := range messages {
439
+ parts := make([]jsonPart, 0, len(item.Parts))
440
+ for _, part := range item.Parts {
441
+ parts = append(parts, jsonPart{
442
+ ID: part.ID,
443
+ Type: part.Type,
444
+ Compacted: part.Compacted,
445
+ Rendered: renderSessionPart(part),
446
+ Payload: part.Payload,
447
+ })
448
+ }
449
+ payload = append(payload, jsonMessage{
450
+ ID: item.Message.ID,
451
+ SessionID: item.Message.SessionID,
452
+ Role: item.Message.Role,
453
+ ParentID: item.Message.ParentID,
454
+ ProviderID: item.Message.ProviderID,
455
+ ModelID: item.Message.ModelID,
456
+ FinishReason: item.Message.FinishReason,
457
+ Error: item.Message.Error,
458
+ CreatedAt: item.Message.CreatedAt,
459
+ Parts: parts,
460
+ })
461
+ }
462
+
463
+ encoded, marshalErr := json.MarshalIndent(payload, "", " ")
464
+ if marshalErr != nil {
465
+ return fmt.Errorf("failed to encode session messages json: %w", marshalErr)
466
+ }
467
+ fmt.Println(string(encoded))
468
+ return nil
469
+ }
470
+
471
+ for _, item := range messages {
472
+ line := fmt.Sprintf("- %s role=%s at=%s", item.Message.ID, item.Message.Role, item.Message.CreatedAt.Format(time.RFC3339))
473
+ if strings.TrimSpace(item.Message.ProviderID) != "" || strings.TrimSpace(item.Message.ModelID) != "" {
474
+ line += fmt.Sprintf(" model=%s/%s", item.Message.ProviderID, item.Message.ModelID)
475
+ }
476
+ if strings.TrimSpace(item.Message.FinishReason) != "" {
477
+ line += fmt.Sprintf(" finish=%s", item.Message.FinishReason)
478
+ }
479
+ if strings.TrimSpace(item.Message.Error) != "" {
480
+ line += fmt.Sprintf(" error=%q", item.Message.Error)
481
+ }
482
+ fmt.Println(line)
483
+
484
+ for _, part := range item.Parts {
485
+ fmt.Printf(" - %s\n", renderSessionPart(part))
486
+ }
487
+ }
488
+
489
+ return nil
490
+ }
491
+
492
+ func renderSessionPart(part storage.SessionPart) string {
493
+ partType := strings.ToLower(strings.TrimSpace(part.Type))
494
+ if partType == "" {
495
+ partType = "unknown"
496
+ }
497
+ compactedSuffix := ""
498
+ if part.Compacted {
499
+ compactedSuffix = " compacted=true"
500
+ }
501
+
502
+ switch partType {
503
+ case "text":
504
+ text := strings.TrimSpace(session.ExtractTextPart(part))
505
+ if text == "" {
506
+ text = compactPayload(part.Payload, 140)
507
+ }
508
+ return fmt.Sprintf("part=text%s text=%q", compactedSuffix, text)
509
+ case "stage":
510
+ var payload map[string]any
511
+ if err := json.Unmarshal([]byte(strings.TrimSpace(part.Payload)), &payload); err != nil {
512
+ return fmt.Sprintf("part=stage%s payload=%q", compactedSuffix, compactPayload(part.Payload, 140))
513
+ }
514
+ actor := extractString(payload, "actor")
515
+ step := extractString(payload, "step")
516
+ message := extractString(payload, "message")
517
+ timestamp := extractString(payload, "timestamp")
518
+ if actor == "" && step == "" && message == "" {
519
+ if status := extractString(payload, "status"); status != "" {
520
+ runID := extractString(payload, "run_id")
521
+ return fmt.Sprintf("part=stage%s run_id=%q status=%q", compactedSuffix, runID, status)
522
+ }
523
+ return fmt.Sprintf("part=stage%s payload=%q", compactedSuffix, compactPayload(part.Payload, 140))
524
+ }
525
+ if len(message) > 140 {
526
+ message = message[:140] + "..."
527
+ }
528
+ return fmt.Sprintf("part=stage%s actor=%q step=%q message=%q at=%q", compactedSuffix, actor, step, message, timestamp)
529
+ case "compaction":
530
+ var payload map[string]any
531
+ if err := json.Unmarshal([]byte(strings.TrimSpace(part.Payload)), &payload); err != nil {
532
+ return fmt.Sprintf("part=compaction%s payload=%q", compactedSuffix, compactPayload(part.Payload, 140))
533
+ }
534
+ estimated, _ := payload["estimated_tokens"].(float64)
535
+ usable, _ := payload["usable_input"].(float64)
536
+ summary := extractString(payload, "summary")
537
+ if len(summary) > 120 {
538
+ summary = summary[:120] + "..."
539
+ }
540
+ return fmt.Sprintf("part=compaction%s estimated_tokens=%.0f usable_input=%.0f summary=%q", compactedSuffix, estimated, usable, summary)
541
+ case "error":
542
+ var payload map[string]any
543
+ if err := json.Unmarshal([]byte(strings.TrimSpace(part.Payload)), &payload); err == nil {
544
+ message := extractString(payload, "message")
545
+ if message != "" {
546
+ return fmt.Sprintf("part=error%s message=%q", compactedSuffix, message)
547
+ }
548
+ }
549
+ return fmt.Sprintf("part=error%s payload=%q", compactedSuffix, compactPayload(part.Payload, 140))
550
+ default:
551
+ return fmt.Sprintf("part=%s%s payload=%q", partType, compactedSuffix, compactPayload(part.Payload, 140))
552
+ }
553
+ }
554
+
555
+ func extractString(payload map[string]any, key string) string {
556
+ if payload == nil {
557
+ return ""
558
+ }
559
+ if value, ok := payload[key].(string); ok {
560
+ return strings.TrimSpace(value)
561
+ }
562
+ return ""
563
+ }
564
+
565
+ func compactPayload(payload string, maxLen int) string {
566
+ trimmed := strings.TrimSpace(payload)
567
+ if trimmed == "" {
568
+ return ""
569
+ }
570
+ if maxLen <= 0 {
571
+ maxLen = 120
572
+ }
573
+ if len(trimmed) <= maxLen {
574
+ return trimmed
575
+ }
576
+
577
+ var parsed map[string]any
578
+ if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil {
579
+ if text, ok := parsed["text"].(string); ok {
580
+ text = strings.TrimSpace(text)
581
+ if len(text) > maxLen {
582
+ return text[:maxLen] + "..."
583
+ }
584
+ return text
585
+ }
586
+ }
587
+
588
+ return trimmed[:maxLen] + "..."
589
+ }
@@ -0,0 +1,54 @@
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "path/filepath"
6
+ "strings"
7
+
8
+ "github.com/furkanbeydemir/orch/internal/storage"
9
+ )
10
+
11
+ type sessionContext struct {
12
+ Store *storage.Store
13
+ ProjectID string
14
+ Session storage.Session
15
+ }
16
+
17
+ func (c *sessionContext) ExecutionRoot(defaultRoot string) string {
18
+ if c == nil {
19
+ return defaultRoot
20
+ }
21
+ worktree := strings.TrimSpace(c.Session.Worktree)
22
+ if worktree == "" {
23
+ return defaultRoot
24
+ }
25
+ if filepath.IsAbs(worktree) {
26
+ return worktree
27
+ }
28
+ return filepath.Join(defaultRoot, worktree)
29
+ }
30
+
31
+ func loadSessionContext(repoRoot string) (*sessionContext, error) {
32
+ store, err := storage.Open(repoRoot)
33
+ if err != nil {
34
+ return nil, fmt.Errorf("failed to open session storage: %w", err)
35
+ }
36
+
37
+ projectID, err := store.GetOrCreateProject()
38
+ if err != nil {
39
+ _ = store.Close()
40
+ return nil, fmt.Errorf("failed to resolve project: %w", err)
41
+ }
42
+
43
+ session, err := store.EnsureDefaultSession(projectID)
44
+ if err != nil {
45
+ _ = store.Close()
46
+ return nil, fmt.Errorf("failed to resolve active session: %w", err)
47
+ }
48
+
49
+ return &sessionContext{
50
+ Store: store,
51
+ ProjectID: projectID,
52
+ Session: session,
53
+ }, nil
54
+ }
@@ -0,0 +1,30 @@
1
+ package cmd
2
+
3
+ import (
4
+ "testing"
5
+ )
6
+
7
+ func TestSessionCommandsLifecycle(t *testing.T) {
8
+ repoRoot := t.TempDir()
9
+ t.Chdir(repoRoot)
10
+
11
+ if err := runSessionCreate(nil, []string{"feature-a"}); err != nil {
12
+ t.Fatalf("create session: %v", err)
13
+ }
14
+
15
+ if err := runSessionCurrent(nil, nil); err != nil {
16
+ t.Fatalf("current session: %v", err)
17
+ }
18
+
19
+ if err := runSessionSelect(nil, []string{"default"}); err != nil {
20
+ t.Fatalf("select default session: %v", err)
21
+ }
22
+
23
+ if err := runSessionClose(nil, []string{"feature-a"}); err != nil {
24
+ t.Fatalf("close session: %v", err)
25
+ }
26
+
27
+ if err := runSessionRuns(nil, []string{"default"}); err != nil {
28
+ t.Fatalf("session runs command: %v", err)
29
+ }
30
+ }