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
@@ -0,0 +1,253 @@
1
+ package confidence
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+
7
+ "github.com/furkanbeydemir/orch/internal/models"
8
+ )
9
+
10
+ type Scorer struct{}
11
+
12
+ func New() *Scorer {
13
+ return &Scorer{}
14
+ }
15
+
16
+ func (s *Scorer) Score(state *models.RunState) *models.ConfidenceReport {
17
+ if state == nil {
18
+ return nil
19
+ }
20
+
21
+ reasons := []string{}
22
+ warnings := []string{}
23
+
24
+ planScore := s.planCompleteness(state, &reasons, &warnings)
25
+ scopeScore := s.scopeCompliance(state, &reasons, &warnings)
26
+ validationScore := s.validationQuality(state, &reasons, &warnings)
27
+ testScore := s.testQuality(state, &reasons, &warnings)
28
+ reviewScore := s.reviewQuality(state, &reasons, &warnings)
29
+ retryPenalty := s.retryPenalty(state, &warnings)
30
+
31
+ score := planScore*0.10 + scopeScore*0.20 + validationScore*0.20 + testScore*0.25 + reviewScore*0.20 - retryPenalty*0.05
32
+ if score < 0 {
33
+ score = 0
34
+ }
35
+ if score > 1 {
36
+ score = 1
37
+ }
38
+
39
+ band := confidenceBand(score)
40
+ if band == "high" {
41
+ reasons = append(reasons, "confidence is high because plan, validation, tests, and review signals are aligned")
42
+ }
43
+ if band == "low" || band == "very_low" {
44
+ warnings = append(warnings, "confidence is below the preferred threshold for hands-off trust")
45
+ }
46
+
47
+ return &models.ConfidenceReport{
48
+ Score: round2(score),
49
+ Band: band,
50
+ Reasons: uniqueNonEmpty(reasons),
51
+ Warnings: uniqueNonEmpty(warnings),
52
+ }
53
+ }
54
+
55
+ func (s *Scorer) planCompleteness(state *models.RunState, reasons, warnings *[]string) float64 {
56
+ if state.Plan == nil {
57
+ *warnings = append(*warnings, "structured plan is missing")
58
+ return 0.2
59
+ }
60
+ score := 0.3
61
+ if strings.TrimSpace(state.Plan.Summary) != "" {
62
+ score += 0.2
63
+ }
64
+ if len(state.Plan.FilesToInspect) > 0 {
65
+ score += 0.15
66
+ }
67
+ if len(state.Plan.FilesToModify) > 0 {
68
+ score += 0.15
69
+ }
70
+ if len(state.Plan.AcceptanceCriteria) > 0 {
71
+ score += 0.1
72
+ *reasons = append(*reasons, "structured plan includes explicit acceptance criteria")
73
+ } else {
74
+ *warnings = append(*warnings, "structured plan acceptance criteria are missing")
75
+ }
76
+ if len(state.Plan.TestRequirements) > 0 || strings.TrimSpace(state.Plan.TestStrategy) != "" {
77
+ score += 0.1
78
+ }
79
+ return clamp01(score)
80
+ }
81
+
82
+ func (s *Scorer) scopeCompliance(state *models.RunState, reasons, warnings *[]string) float64 {
83
+ for _, result := range state.ValidationResults {
84
+ if result.Name != "scope_compliance" {
85
+ continue
86
+ }
87
+ switch result.Status {
88
+ case models.ValidationPass:
89
+ *reasons = append(*reasons, "scope compliance gate passed")
90
+ return 1.0
91
+ case models.ValidationWarn:
92
+ *warnings = append(*warnings, result.Summary)
93
+ return 0.6
94
+ default:
95
+ *warnings = append(*warnings, result.Summary)
96
+ return 0.1
97
+ }
98
+ }
99
+ *warnings = append(*warnings, "scope compliance gate result is missing")
100
+ return 0.4
101
+ }
102
+
103
+ func (s *Scorer) validationQuality(state *models.RunState, reasons, warnings *[]string) float64 {
104
+ if len(state.ValidationResults) == 0 {
105
+ *warnings = append(*warnings, "validation results are missing")
106
+ return 0.3
107
+ }
108
+ passWeight := 0.0
109
+ totalWeight := 0.0
110
+ for _, result := range state.ValidationResults {
111
+ weight := validationWeight(result.Severity)
112
+ totalWeight += weight
113
+ switch result.Status {
114
+ case models.ValidationPass:
115
+ passWeight += weight
116
+ case models.ValidationWarn:
117
+ passWeight += weight * 0.5
118
+ *warnings = append(*warnings, result.Summary)
119
+ default:
120
+ *warnings = append(*warnings, result.Summary)
121
+ }
122
+ }
123
+ if totalWeight == 0 {
124
+ return 0.3
125
+ }
126
+ ratio := passWeight / totalWeight
127
+ if ratio >= 0.9 {
128
+ *reasons = append(*reasons, "validation gates passed with high coverage")
129
+ }
130
+ return clamp01(ratio)
131
+ }
132
+
133
+ func (s *Scorer) testQuality(state *models.RunState, reasons, warnings *[]string) float64 {
134
+ if strings.TrimSpace(state.TestResults) == "" {
135
+ *warnings = append(*warnings, "test output is missing")
136
+ return 0.25
137
+ }
138
+ score := 0.8
139
+ lower := strings.ToLower(state.TestResults)
140
+ if len(state.TestFailures) > 0 {
141
+ score = 0.2
142
+ for _, failure := range state.TestFailures {
143
+ *warnings = append(*warnings, failure.Code+": "+failure.Summary)
144
+ if failure.Flaky {
145
+ score = 0.35
146
+ }
147
+ }
148
+ } else if strings.Contains(lower, "fail") || strings.Contains(lower, "panic") {
149
+ *warnings = append(*warnings, "test output indicates instability or failure history")
150
+ score = 0.2
151
+ } else {
152
+ *reasons = append(*reasons, "test output was recorded for the run")
153
+ }
154
+ if state.Retries.Testing > 0 {
155
+ score -= 0.2
156
+ *warnings = append(*warnings, fmt.Sprintf("tests required %d retry attempt(s)", state.Retries.Testing))
157
+ }
158
+ return clamp01(score)
159
+ }
160
+
161
+ func (s *Scorer) reviewQuality(state *models.RunState, reasons, warnings *[]string) float64 {
162
+ if state.ReviewScorecard == nil {
163
+ *warnings = append(*warnings, "review scorecard is missing")
164
+ return 0.3
165
+ }
166
+ avg := float64(
167
+ state.ReviewScorecard.RequirementCoverage+
168
+ state.ReviewScorecard.ScopeControl+
169
+ state.ReviewScorecard.RegressionRisk+
170
+ state.ReviewScorecard.Readability+
171
+ state.ReviewScorecard.Maintainability+
172
+ state.ReviewScorecard.TestAdequacy,
173
+ ) / 60.0
174
+ if state.ReviewScorecard.Decision == models.ReviewAccept {
175
+ *reasons = append(*reasons, "review rubric accepted the patch")
176
+ } else {
177
+ *warnings = append(*warnings, "review rubric did not fully accept the patch")
178
+ avg *= 0.7
179
+ }
180
+ return clamp01(avg)
181
+ }
182
+
183
+ func (s *Scorer) retryPenalty(state *models.RunState, warnings *[]string) float64 {
184
+ total := state.Retries.Validation + state.Retries.Testing + state.Retries.Review
185
+ if total == 0 {
186
+ return 0
187
+ }
188
+ *warnings = append(*warnings, fmt.Sprintf("run used %d total retry attempt(s)", total))
189
+ if total >= 3 {
190
+ return 1
191
+ }
192
+ if total == 2 {
193
+ return 0.6
194
+ }
195
+ return 0.3
196
+ }
197
+
198
+ func validationWeight(severity models.ValidationSeverity) float64 {
199
+ switch severity {
200
+ case models.SeverityCritical:
201
+ return 1.5
202
+ case models.SeverityHigh:
203
+ return 1.2
204
+ case models.SeverityMedium:
205
+ return 1.0
206
+ default:
207
+ return 0.8
208
+ }
209
+ }
210
+
211
+ func confidenceBand(score float64) string {
212
+ switch {
213
+ case score >= 0.85:
214
+ return "high"
215
+ case score >= 0.70:
216
+ return "medium"
217
+ case score >= 0.50:
218
+ return "low"
219
+ default:
220
+ return "very_low"
221
+ }
222
+ }
223
+
224
+ func clamp01(v float64) float64 {
225
+ if v < 0 {
226
+ return 0
227
+ }
228
+ if v > 1 {
229
+ return 1
230
+ }
231
+ return v
232
+ }
233
+
234
+ func round2(v float64) float64 {
235
+ return float64(int(v*100+0.5)) / 100
236
+ }
237
+
238
+ func uniqueNonEmpty(values []string) []string {
239
+ result := make([]string, 0, len(values))
240
+ seen := map[string]struct{}{}
241
+ for _, value := range values {
242
+ trimmed := strings.TrimSpace(value)
243
+ if trimmed == "" {
244
+ continue
245
+ }
246
+ if _, ok := seen[trimmed]; ok {
247
+ continue
248
+ }
249
+ seen[trimmed] = struct{}{}
250
+ result = append(result, trimmed)
251
+ }
252
+ return result
253
+ }
@@ -0,0 +1,83 @@
1
+ package confidence
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/furkanbeydemir/orch/internal/models"
8
+ )
9
+
10
+ func TestScoreHighConfidenceRun(t *testing.T) {
11
+ scorer := New()
12
+ state := &models.RunState{
13
+ Task: models.Task{ID: "task-1", Description: "fix auth bug", CreatedAt: time.Now()},
14
+ Plan: &models.Plan{
15
+ Summary: "Fix auth bug",
16
+ FilesToInspect: []string{"auth.go"},
17
+ FilesToModify: []string{"auth.go"},
18
+ AcceptanceCriteria: []models.AcceptanceCriterion{{ID: "ac-1", Description: "Bug fixed"}},
19
+ TestRequirements: []string{"Run go test ./..."},
20
+ },
21
+ ValidationResults: []models.ValidationResult{
22
+ {Name: "scope_compliance", Status: models.ValidationPass, Severity: models.SeverityLow},
23
+ {Name: "patch_hygiene", Status: models.ValidationPass, Severity: models.SeverityLow},
24
+ {Name: "plan_compliance", Status: models.ValidationPass, Severity: models.SeverityLow},
25
+ },
26
+ ReviewScorecard: &models.ReviewScorecard{
27
+ RequirementCoverage: 9,
28
+ ScopeControl: 9,
29
+ RegressionRisk: 8,
30
+ Readability: 8,
31
+ Maintainability: 8,
32
+ TestAdequacy: 9,
33
+ Decision: models.ReviewAccept,
34
+ },
35
+ TestResults: "ok auth",
36
+ }
37
+
38
+ report := scorer.Score(state)
39
+ if report == nil {
40
+ t.Fatalf("expected confidence report")
41
+ }
42
+ if report.Band != "high" && report.Band != "medium" {
43
+ t.Fatalf("expected healthy confidence band, got %s", report.Band)
44
+ }
45
+ if report.Score <= 0.69 {
46
+ t.Fatalf("expected higher score, got %f", report.Score)
47
+ }
48
+ }
49
+
50
+ func TestScoreLowConfidenceRun(t *testing.T) {
51
+ scorer := New()
52
+ state := &models.RunState{
53
+ Task: models.Task{ID: "task-2", Description: "feature", CreatedAt: time.Now()},
54
+ Plan: &models.Plan{},
55
+ ValidationResults: []models.ValidationResult{
56
+ {Name: "scope_compliance", Status: models.ValidationFail, Severity: models.SeverityHigh, Summary: "scope fail"},
57
+ },
58
+ ReviewScorecard: &models.ReviewScorecard{
59
+ RequirementCoverage: 4,
60
+ ScopeControl: 3,
61
+ RegressionRisk: 4,
62
+ Readability: 5,
63
+ Maintainability: 5,
64
+ TestAdequacy: 2,
65
+ Decision: models.ReviewRevise,
66
+ },
67
+ Retries: models.RetryState{Validation: 2, Testing: 1},
68
+ }
69
+
70
+ report := scorer.Score(state)
71
+ if report == nil {
72
+ t.Fatalf("expected confidence report")
73
+ }
74
+ if report.Band != "low" && report.Band != "very_low" {
75
+ t.Fatalf("expected low confidence band, got %s", report.Band)
76
+ }
77
+ if report.Score >= 0.70 {
78
+ t.Fatalf("expected low score, got %f", report.Score)
79
+ }
80
+ if len(report.Warnings) == 0 {
81
+ t.Fatalf("expected warnings for low confidence")
82
+ }
83
+ }
@@ -0,0 +1,331 @@
1
+ package config
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ )
9
+
10
+ const (
11
+ OrchDir = ".orch"
12
+ ConfigFile = "config.json"
13
+ RepoMapFile = "repo-map.json"
14
+ RunsDir = "runs"
15
+ )
16
+
17
+ type Config struct {
18
+ Version int `json:"version"`
19
+ Models ModelConfig `json:"models"`
20
+ Commands CommandConfig `json:"commands"`
21
+ Patch PatchConfig `json:"patch"`
22
+ Safety SafetyConfig `json:"safety"`
23
+ Provider ProviderConfig `json:"provider"`
24
+ }
25
+
26
+ type ModelConfig struct {
27
+ Planner string `json:"planner"`
28
+ Coder string `json:"coder"`
29
+ Reviewer string `json:"reviewer"`
30
+ }
31
+
32
+ type CommandConfig struct {
33
+ Test string `json:"test"`
34
+ Lint string `json:"lint"`
35
+ }
36
+
37
+ type PatchConfig struct {
38
+ MaxFiles int `json:"maxFiles"`
39
+ MaxLines int `json:"maxLines"`
40
+ }
41
+
42
+ type SafetyConfig struct {
43
+ DryRun bool `json:"dryRun"`
44
+ RequireDestructiveApproval bool `json:"requireDestructiveApproval"`
45
+ LockStaleAfterSeconds int `json:"lockStaleAfterSeconds"`
46
+ Retry RetryPolicyConfig `json:"retry"`
47
+ Confidence ConfidencePolicyConfig `json:"confidence"`
48
+ FeatureFlags SafetyFeatureFlags `json:"featureFlags"`
49
+ }
50
+
51
+ type RetryPolicyConfig struct {
52
+ ValidationMax int `json:"validationMax"`
53
+ TestMax int `json:"testMax"`
54
+ ReviewMax int `json:"reviewMax"`
55
+ }
56
+
57
+ type ConfidencePolicyConfig struct {
58
+ CompleteMin float64 `json:"completeMin"`
59
+ FailBelow float64 `json:"failBelow"`
60
+ }
61
+
62
+ type SafetyFeatureFlags struct {
63
+ PermissionMode bool `json:"permissionMode"`
64
+ RepoLock bool `json:"repoLock"`
65
+ RetryLimits bool `json:"retryLimits"`
66
+ PatchConflictReporting bool `json:"patchConflictReporting"`
67
+ ConfidenceEnforcement bool `json:"confidenceEnforcement"`
68
+ }
69
+
70
+ type ProviderConfig struct {
71
+ Default string `json:"default"`
72
+ OpenAI OpenAIProviderConfig `json:"openai"`
73
+ Flags ProviderFeatureFlags `json:"flags"`
74
+ }
75
+
76
+ type OpenAIProviderConfig struct {
77
+ APIKeyEnv string `json:"apiKeyEnv"`
78
+ AccountTokenEnv string `json:"accountTokenEnv"`
79
+ AuthMode string `json:"authMode"`
80
+ BaseURL string `json:"baseURL"`
81
+ ReasoningEffort string `json:"reasoningEffort"`
82
+ TimeoutSeconds int `json:"timeoutSeconds"`
83
+ MaxRetries int `json:"maxRetries"`
84
+ Models ProviderRoleModels `json:"models"`
85
+ }
86
+
87
+ type ProviderRoleModels struct {
88
+ Planner string `json:"planner"`
89
+ Coder string `json:"coder"`
90
+ Reviewer string `json:"reviewer"`
91
+ }
92
+
93
+ type ProviderFeatureFlags struct {
94
+ OpenAIEnabled bool `json:"openaiEnabled"`
95
+ }
96
+
97
+ func DefaultConfig() *Config {
98
+ return &Config{
99
+ Version: 1,
100
+ Models: ModelConfig{
101
+ Planner: "openai:gpt-4o-mini",
102
+ Coder: "anthropic:claude-sonnet",
103
+ Reviewer: "openai:gpt-4o-mini",
104
+ },
105
+ Commands: CommandConfig{
106
+ Test: "go test ./...",
107
+ Lint: "go vet ./...",
108
+ },
109
+ Patch: PatchConfig{
110
+ MaxFiles: 10,
111
+ MaxLines: 800,
112
+ },
113
+ Safety: SafetyConfig{
114
+ DryRun: true,
115
+ RequireDestructiveApproval: true,
116
+ LockStaleAfterSeconds: 3600,
117
+ Retry: RetryPolicyConfig{
118
+ ValidationMax: 2,
119
+ TestMax: 2,
120
+ ReviewMax: 2,
121
+ },
122
+ Confidence: ConfidencePolicyConfig{
123
+ CompleteMin: 0.70,
124
+ FailBelow: 0.50,
125
+ },
126
+ FeatureFlags: SafetyFeatureFlags{
127
+ PermissionMode: true,
128
+ RepoLock: true,
129
+ RetryLimits: true,
130
+ PatchConflictReporting: true,
131
+ ConfidenceEnforcement: true,
132
+ },
133
+ },
134
+ Provider: ProviderConfig{
135
+ Default: "openai",
136
+ OpenAI: OpenAIProviderConfig{
137
+ APIKeyEnv: "OPENAI_API_KEY",
138
+ AccountTokenEnv: "OPENAI_ACCOUNT_TOKEN",
139
+ AuthMode: "api_key",
140
+ BaseURL: "https://api.openai.com/v1",
141
+ ReasoningEffort: "medium",
142
+ TimeoutSeconds: 90,
143
+ MaxRetries: 3,
144
+ Models: ProviderRoleModels{
145
+ Planner: "gpt-5.3-codex",
146
+ Coder: "gpt-5.3-codex",
147
+ Reviewer: "gpt-5.3-codex",
148
+ },
149
+ },
150
+ Flags: ProviderFeatureFlags{
151
+ OpenAIEnabled: true,
152
+ },
153
+ },
154
+ }
155
+ }
156
+
157
+ func Load(repoRoot string) (*Config, error) {
158
+ configPath := filepath.Join(repoRoot, OrchDir, ConfigFile)
159
+
160
+ data, err := os.ReadFile(configPath)
161
+ if err != nil {
162
+ return nil, fmt.Errorf("failed to read config file: %w", err)
163
+ }
164
+
165
+ var cfg Config
166
+ if err := json.Unmarshal(data, &cfg); err != nil {
167
+ return nil, fmt.Errorf("failed to parse config: %w", err)
168
+ }
169
+
170
+ applyDefaults(&cfg, data)
171
+
172
+ return &cfg, nil
173
+ }
174
+
175
+ func applyDefaults(cfg *Config, rawJSON []byte) {
176
+ defaults := DefaultConfig()
177
+ presence := parsePresence(rawJSON)
178
+
179
+ if cfg.Safety.LockStaleAfterSeconds <= 0 {
180
+ cfg.Safety.LockStaleAfterSeconds = defaults.Safety.LockStaleAfterSeconds
181
+ }
182
+
183
+ if cfg.Safety.Retry.ValidationMax <= 0 {
184
+ cfg.Safety.Retry.ValidationMax = defaults.Safety.Retry.ValidationMax
185
+ }
186
+ if cfg.Safety.Retry.TestMax <= 0 {
187
+ cfg.Safety.Retry.TestMax = defaults.Safety.Retry.TestMax
188
+ }
189
+ if cfg.Safety.Retry.ReviewMax <= 0 {
190
+ cfg.Safety.Retry.ReviewMax = defaults.Safety.Retry.ReviewMax
191
+ }
192
+ if cfg.Safety.Confidence.CompleteMin <= 0 {
193
+ cfg.Safety.Confidence.CompleteMin = defaults.Safety.Confidence.CompleteMin
194
+ }
195
+ if cfg.Safety.Confidence.FailBelow <= 0 {
196
+ cfg.Safety.Confidence.FailBelow = defaults.Safety.Confidence.FailBelow
197
+ }
198
+
199
+ if !presence.featureFlagsPresent {
200
+ cfg.Safety.FeatureFlags = defaults.Safety.FeatureFlags
201
+ }
202
+ if !presence.confidenceEnforcementPresent {
203
+ cfg.Safety.FeatureFlags.ConfidenceEnforcement = defaults.Safety.FeatureFlags.ConfidenceEnforcement
204
+ }
205
+
206
+ if !presence.requireDestructiveApprovalPresent {
207
+ cfg.Safety.RequireDestructiveApproval = defaults.Safety.RequireDestructiveApproval
208
+ }
209
+
210
+ if !presence.providerPresent {
211
+ cfg.Provider = defaults.Provider
212
+ } else {
213
+ if cfg.Provider.Default == "" {
214
+ cfg.Provider.Default = defaults.Provider.Default
215
+ }
216
+ if cfg.Provider.OpenAI.APIKeyEnv == "" {
217
+ cfg.Provider.OpenAI.APIKeyEnv = defaults.Provider.OpenAI.APIKeyEnv
218
+ }
219
+ if cfg.Provider.OpenAI.AccountTokenEnv == "" {
220
+ cfg.Provider.OpenAI.AccountTokenEnv = defaults.Provider.OpenAI.AccountTokenEnv
221
+ }
222
+ if cfg.Provider.OpenAI.AuthMode == "" {
223
+ cfg.Provider.OpenAI.AuthMode = defaults.Provider.OpenAI.AuthMode
224
+ }
225
+ if cfg.Provider.OpenAI.BaseURL == "" {
226
+ cfg.Provider.OpenAI.BaseURL = defaults.Provider.OpenAI.BaseURL
227
+ }
228
+ if cfg.Provider.OpenAI.ReasoningEffort == "" {
229
+ cfg.Provider.OpenAI.ReasoningEffort = defaults.Provider.OpenAI.ReasoningEffort
230
+ }
231
+ if cfg.Provider.OpenAI.TimeoutSeconds <= 0 {
232
+ cfg.Provider.OpenAI.TimeoutSeconds = defaults.Provider.OpenAI.TimeoutSeconds
233
+ }
234
+ if cfg.Provider.OpenAI.MaxRetries <= 0 {
235
+ cfg.Provider.OpenAI.MaxRetries = defaults.Provider.OpenAI.MaxRetries
236
+ }
237
+ if cfg.Provider.OpenAI.Models.Planner == "" {
238
+ cfg.Provider.OpenAI.Models.Planner = defaults.Provider.OpenAI.Models.Planner
239
+ }
240
+ if cfg.Provider.OpenAI.Models.Coder == "" {
241
+ cfg.Provider.OpenAI.Models.Coder = defaults.Provider.OpenAI.Models.Coder
242
+ }
243
+ if cfg.Provider.OpenAI.Models.Reviewer == "" {
244
+ cfg.Provider.OpenAI.Models.Reviewer = defaults.Provider.OpenAI.Models.Reviewer
245
+ }
246
+ if !presence.providerFlagsPresent {
247
+ cfg.Provider.Flags = defaults.Provider.Flags
248
+ }
249
+ }
250
+ }
251
+
252
+ type fieldPresence struct {
253
+ featureFlagsPresent bool
254
+ confidenceEnforcementPresent bool
255
+ requireDestructiveApprovalPresent bool
256
+ providerPresent bool
257
+ providerFlagsPresent bool
258
+ }
259
+
260
+ func parsePresence(rawJSON []byte) fieldPresence {
261
+ result := fieldPresence{}
262
+
263
+ var root map[string]json.RawMessage
264
+ if err := json.Unmarshal(rawJSON, &root); err != nil {
265
+ return result
266
+ }
267
+
268
+ safetyRaw, ok := root["safety"]
269
+ if !ok {
270
+ return result
271
+ }
272
+
273
+ var safety map[string]json.RawMessage
274
+ if err := json.Unmarshal(safetyRaw, &safety); err != nil {
275
+ return result
276
+ }
277
+
278
+ if featureFlagsRaw, ok := safety["featureFlags"]; ok {
279
+ result.featureFlagsPresent = true
280
+ var featureFlags map[string]json.RawMessage
281
+ if err := json.Unmarshal(featureFlagsRaw, &featureFlags); err == nil {
282
+ _, result.confidenceEnforcementPresent = featureFlags["confidenceEnforcement"]
283
+ }
284
+ }
285
+ _, result.requireDestructiveApprovalPresent = safety["requireDestructiveApproval"]
286
+
287
+ providerRaw, ok := root["provider"]
288
+ if ok {
289
+ result.providerPresent = true
290
+ var provider map[string]json.RawMessage
291
+ if err := json.Unmarshal(providerRaw, &provider); err == nil {
292
+ _, result.providerFlagsPresent = provider["flags"]
293
+ }
294
+ }
295
+
296
+ return result
297
+ }
298
+
299
+ func Save(repoRoot string, cfg *Config) error {
300
+ orchDir := filepath.Join(repoRoot, OrchDir)
301
+ if err := os.MkdirAll(orchDir, 0o755); err != nil {
302
+ return fmt.Errorf("failed to create .orch directory: %w", err)
303
+ }
304
+
305
+ data, err := json.MarshalIndent(cfg, "", " ")
306
+ if err != nil {
307
+ return fmt.Errorf("failed to serialize config: %w", err)
308
+ }
309
+
310
+ configPath := filepath.Join(orchDir, ConfigFile)
311
+ if err := os.WriteFile(configPath, data, 0o644); err != nil {
312
+ return fmt.Errorf("failed to write config file: %w", err)
313
+ }
314
+
315
+ return nil
316
+ }
317
+
318
+ func EnsureOrchDir(repoRoot string) error {
319
+ dirs := []string{
320
+ filepath.Join(repoRoot, OrchDir),
321
+ filepath.Join(repoRoot, OrchDir, RunsDir),
322
+ }
323
+
324
+ for _, dir := range dirs {
325
+ if err := os.MkdirAll(dir, 0o755); err != nil {
326
+ return fmt.Errorf("failed to create directory %s: %w", dir, err)
327
+ }
328
+ }
329
+
330
+ return nil
331
+ }