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,287 @@
1
+ package auth
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "time"
10
+
11
+ "github.com/furkanbeydemir/orch/internal/config"
12
+ )
13
+
14
+ const authFile = "auth.json"
15
+
16
+ type State struct {
17
+ Provider string `json:"provider"`
18
+ Mode string `json:"mode"`
19
+ AccessToken string `json:"accessToken"`
20
+ RefreshToken string `json:"refreshToken,omitempty"`
21
+ ExpiresAt time.Time `json:"expiresAt,omitempty"`
22
+ AccountID string `json:"accountId,omitempty"`
23
+ Email string `json:"email,omitempty"`
24
+ UpdatedAt time.Time `json:"updatedAt"`
25
+ }
26
+
27
+ type Credential struct {
28
+ Type string `json:"type"`
29
+ Key string `json:"key,omitempty"`
30
+ AccessToken string `json:"accessToken,omitempty"`
31
+ RefreshToken string `json:"refreshToken,omitempty"`
32
+ ExpiresAt time.Time `json:"expiresAt,omitempty"`
33
+ AccountID string `json:"accountId,omitempty"`
34
+ Email string `json:"email,omitempty"`
35
+ UpdatedAt time.Time `json:"updatedAt"`
36
+ }
37
+
38
+ func Load(repoRoot string) (*State, error) {
39
+ cred, err := Get(repoRoot, "openai")
40
+ if err != nil {
41
+ return nil, err
42
+ }
43
+ if cred == nil {
44
+ return nil, nil
45
+ }
46
+
47
+ state := &State{
48
+ Provider: "openai",
49
+ UpdatedAt: cred.UpdatedAt,
50
+ RefreshToken: strings.TrimSpace(cred.RefreshToken),
51
+ ExpiresAt: cred.ExpiresAt,
52
+ AccountID: strings.TrimSpace(cred.AccountID),
53
+ Email: strings.TrimSpace(cred.Email),
54
+ }
55
+
56
+ switch strings.TrimSpace(strings.ToLower(cred.Type)) {
57
+ case "oauth", "account":
58
+ state.Mode = "account"
59
+ state.AccessToken = strings.TrimSpace(cred.AccessToken)
60
+ case "api", "api_key":
61
+ state.Mode = "api_key"
62
+ default:
63
+ state.Mode = strings.TrimSpace(cred.Type)
64
+ }
65
+
66
+ return state, nil
67
+ }
68
+
69
+ func LoadAll(repoRoot string) (map[string]Credential, error) {
70
+ path := filepath.Join(repoRoot, config.OrchDir, authFile)
71
+ data, err := os.ReadFile(path)
72
+ if err != nil {
73
+ if os.IsNotExist(err) {
74
+ return map[string]Credential{}, nil
75
+ }
76
+ return nil, fmt.Errorf("read auth state: %w", err)
77
+ }
78
+
79
+ parsed := map[string]Credential{}
80
+ if err := json.Unmarshal(data, &parsed); err == nil {
81
+ clean := map[string]Credential{}
82
+ for provider, cred := range parsed {
83
+ id := strings.ToLower(strings.TrimSpace(provider))
84
+ if id == "" {
85
+ continue
86
+ }
87
+
88
+ cred.Type = strings.ToLower(strings.TrimSpace(cred.Type))
89
+ cred.Key = strings.TrimSpace(cred.Key)
90
+ cred.AccessToken = strings.TrimSpace(cred.AccessToken)
91
+ cred.RefreshToken = strings.TrimSpace(cred.RefreshToken)
92
+ cred.AccountID = strings.TrimSpace(cred.AccountID)
93
+ cred.Email = strings.TrimSpace(cred.Email)
94
+
95
+ if cred.Type == "" {
96
+ continue
97
+ }
98
+ if cred.Type == "api_key" {
99
+ cred.Type = "api"
100
+ }
101
+ if cred.Type == "account" {
102
+ cred.Type = "oauth"
103
+ }
104
+ clean[id] = cred
105
+ }
106
+ if len(clean) > 0 {
107
+ return clean, nil
108
+ }
109
+ }
110
+
111
+ var state State
112
+ if err := json.Unmarshal(data, &state); err != nil {
113
+ return nil, fmt.Errorf("parse auth state: %w", err)
114
+ }
115
+
116
+ provider := strings.ToLower(strings.TrimSpace(state.Provider))
117
+ if provider == "" {
118
+ provider = "openai"
119
+ }
120
+
121
+ mode := strings.ToLower(strings.TrimSpace(state.Mode))
122
+ cred := Credential{
123
+ RefreshToken: strings.TrimSpace(state.RefreshToken),
124
+ ExpiresAt: state.ExpiresAt,
125
+ AccountID: strings.TrimSpace(state.AccountID),
126
+ Email: strings.TrimSpace(state.Email),
127
+ UpdatedAt: state.UpdatedAt,
128
+ }
129
+ if mode == "account" {
130
+ cred.Type = "oauth"
131
+ cred.AccessToken = strings.TrimSpace(state.AccessToken)
132
+ }
133
+ if mode == "api_key" {
134
+ cred.Type = "api"
135
+ cred.Key = strings.TrimSpace(state.AccessToken)
136
+ }
137
+ if cred.Type == "" {
138
+ cred.Type = mode
139
+ }
140
+
141
+ if cred.Type == "" {
142
+ return map[string]Credential{}, nil
143
+ }
144
+
145
+ return map[string]Credential{provider: cred}, nil
146
+ }
147
+
148
+ func Save(repoRoot string, state *State) error {
149
+ if state == nil {
150
+ return fmt.Errorf("auth state cannot be nil")
151
+ }
152
+ provider := strings.ToLower(strings.TrimSpace(state.Provider))
153
+ if provider == "" {
154
+ provider = "openai"
155
+ }
156
+
157
+ mode := strings.ToLower(strings.TrimSpace(state.Mode))
158
+ cred := Credential{
159
+ Email: strings.TrimSpace(state.Email),
160
+ AccessToken: strings.TrimSpace(state.AccessToken),
161
+ RefreshToken: strings.TrimSpace(state.RefreshToken),
162
+ ExpiresAt: state.ExpiresAt,
163
+ AccountID: strings.TrimSpace(state.AccountID),
164
+ }
165
+ if mode == "account" {
166
+ cred.Type = "oauth"
167
+ }
168
+ if mode == "api_key" {
169
+ cred.Type = "api"
170
+ cred.Key = strings.TrimSpace(state.AccessToken)
171
+ cred.AccessToken = ""
172
+ }
173
+ if cred.Type == "" {
174
+ return fmt.Errorf("unsupported auth mode: %s", state.Mode)
175
+ }
176
+
177
+ return Set(repoRoot, provider, cred)
178
+ }
179
+
180
+ func Clear(repoRoot string) error {
181
+ path := filepath.Join(repoRoot, config.OrchDir, authFile)
182
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
183
+ return fmt.Errorf("remove auth state: %w", err)
184
+ }
185
+ return nil
186
+ }
187
+
188
+ func Get(repoRoot, provider string) (*Credential, error) {
189
+ all, err := LoadAll(repoRoot)
190
+ if err != nil {
191
+ return nil, err
192
+ }
193
+ id := strings.ToLower(strings.TrimSpace(provider))
194
+ if id == "" {
195
+ return nil, nil
196
+ }
197
+ cred, ok := all[id]
198
+ if !ok {
199
+ return nil, nil
200
+ }
201
+ return &cred, nil
202
+ }
203
+
204
+ func Set(repoRoot, provider string, cred Credential) error {
205
+ id := strings.ToLower(strings.TrimSpace(provider))
206
+ if id == "" {
207
+ return fmt.Errorf("provider is required")
208
+ }
209
+
210
+ kind := strings.ToLower(strings.TrimSpace(cred.Type))
211
+ if kind == "api_key" {
212
+ kind = "api"
213
+ }
214
+ if kind == "account" {
215
+ kind = "oauth"
216
+ }
217
+ if kind != "api" && kind != "oauth" && kind != "wellknown" {
218
+ return fmt.Errorf("unsupported credential type: %s", cred.Type)
219
+ }
220
+ if kind == "api" && strings.TrimSpace(cred.Key) == "" {
221
+ return fmt.Errorf("api credential key cannot be empty")
222
+ }
223
+ if kind == "oauth" && strings.TrimSpace(cred.AccessToken) == "" {
224
+ return fmt.Errorf("oauth access token cannot be empty")
225
+ }
226
+
227
+ if err := config.EnsureOrchDir(repoRoot); err != nil {
228
+ return err
229
+ }
230
+
231
+ all, err := LoadAll(repoRoot)
232
+ if err != nil {
233
+ return err
234
+ }
235
+
236
+ cred.Type = kind
237
+ cred.Key = strings.TrimSpace(cred.Key)
238
+ cred.AccessToken = strings.TrimSpace(cred.AccessToken)
239
+ cred.RefreshToken = strings.TrimSpace(cred.RefreshToken)
240
+ cred.AccountID = strings.TrimSpace(cred.AccountID)
241
+ cred.Email = strings.TrimSpace(cred.Email)
242
+ cred.UpdatedAt = time.Now().UTC()
243
+
244
+ all[id] = cred
245
+
246
+ data, err := json.MarshalIndent(all, "", " ")
247
+ if err != nil {
248
+ return fmt.Errorf("serialize auth state: %w", err)
249
+ }
250
+
251
+ path := filepath.Join(repoRoot, config.OrchDir, authFile)
252
+ if err := os.WriteFile(path, data, 0o600); err != nil {
253
+ return fmt.Errorf("write auth state: %w", err)
254
+ }
255
+
256
+ return nil
257
+ }
258
+
259
+ func Remove(repoRoot, provider string) error {
260
+ all, err := LoadAll(repoRoot)
261
+ if err != nil {
262
+ return err
263
+ }
264
+ id := strings.ToLower(strings.TrimSpace(provider))
265
+ if id == "" {
266
+ return fmt.Errorf("provider is required")
267
+ }
268
+ if _, ok := all[id]; !ok {
269
+ return nil
270
+ }
271
+ delete(all, id)
272
+
273
+ if len(all) == 0 {
274
+ return Clear(repoRoot)
275
+ }
276
+
277
+ data, err := json.MarshalIndent(all, "", " ")
278
+ if err != nil {
279
+ return fmt.Errorf("serialize auth state: %w", err)
280
+ }
281
+
282
+ path := filepath.Join(repoRoot, config.OrchDir, authFile)
283
+ if err := os.WriteFile(path, data, 0o600); err != nil {
284
+ return fmt.Errorf("write auth state: %w", err)
285
+ }
286
+ return nil
287
+ }
@@ -0,0 +1,174 @@
1
+ package confidence
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+
7
+ "github.com/furkanbeydemir/orch/internal/config"
8
+ "github.com/furkanbeydemir/orch/internal/models"
9
+ )
10
+
11
+ type Policy struct {
12
+ enabled bool
13
+ completeMin float64
14
+ failBelow float64
15
+ }
16
+
17
+ func NewPolicy(cfg *config.Config) *Policy {
18
+ defaults := config.DefaultConfig()
19
+ policy := &Policy{
20
+ enabled: defaults.Safety.FeatureFlags.ConfidenceEnforcement,
21
+ completeMin: defaults.Safety.Confidence.CompleteMin,
22
+ failBelow: defaults.Safety.Confidence.FailBelow,
23
+ }
24
+ if cfg == nil {
25
+ return policy
26
+ }
27
+ policy.enabled = cfg.Safety.FeatureFlags.ConfidenceEnforcement
28
+ if cfg.Safety.Confidence.CompleteMin > 0 {
29
+ policy.completeMin = cfg.Safety.Confidence.CompleteMin
30
+ }
31
+ if cfg.Safety.Confidence.FailBelow > 0 {
32
+ policy.failBelow = cfg.Safety.Confidence.FailBelow
33
+ }
34
+ return policy
35
+ }
36
+
37
+ func (p *Policy) Apply(state *models.RunState) error {
38
+ if state == nil {
39
+ return nil
40
+ }
41
+
42
+ appendOrReplaceReviewGate(state, models.ValidationResult{
43
+ Name: "review_scorecard_valid",
44
+ Stage: "review",
45
+ Status: models.ValidationPass,
46
+ Severity: models.SeverityLow,
47
+ Summary: "review scorecard produced successfully",
48
+ })
49
+
50
+ if state.ReviewScorecard == nil {
51
+ appendOrReplaceReviewGate(state, models.ValidationResult{
52
+ Name: "review_scorecard_valid",
53
+ Stage: "review",
54
+ Status: models.ValidationFail,
55
+ Severity: models.SeverityHigh,
56
+ Summary: "review scorecard is missing",
57
+ })
58
+ return fmt.Errorf("review scorecard is missing")
59
+ }
60
+ if state.Review == nil {
61
+ appendOrReplaceReviewGate(state, models.ValidationResult{
62
+ Name: "review_decision_threshold_met",
63
+ Stage: "review",
64
+ Status: models.ValidationFail,
65
+ Severity: models.SeverityHigh,
66
+ Summary: "review result is missing",
67
+ })
68
+ return fmt.Errorf("review result is missing")
69
+ }
70
+ if state.Confidence == nil {
71
+ appendOrReplaceReviewGate(state, models.ValidationResult{
72
+ Name: "review_decision_threshold_met",
73
+ Stage: "review",
74
+ Status: models.ValidationFail,
75
+ Severity: models.SeverityHigh,
76
+ Summary: "confidence report is missing",
77
+ })
78
+ return fmt.Errorf("confidence report is missing")
79
+ }
80
+
81
+ if !p.enabled {
82
+ appendOrReplaceReviewGate(state, models.ValidationResult{
83
+ Name: "review_decision_threshold_met",
84
+ Stage: "review",
85
+ Status: models.ValidationPass,
86
+ Severity: models.SeverityLow,
87
+ Summary: "confidence enforcement disabled; review threshold considered satisfied",
88
+ })
89
+ return nil
90
+ }
91
+
92
+ score := state.Confidence.Score
93
+ if score < p.failBelow {
94
+ message := fmt.Sprintf("confidence %.2f is below fail threshold %.2f", score, p.failBelow)
95
+ markReviewRevise(state, message)
96
+ appendOrReplaceReviewGate(state, models.ValidationResult{
97
+ Name: "review_decision_threshold_met",
98
+ Stage: "review",
99
+ Status: models.ValidationFail,
100
+ Severity: models.SeverityHigh,
101
+ Summary: message,
102
+ ActionableItems: []string{"Do not complete the run; inspect the confidence warnings and regenerate the patch with tighter scope and stronger verification."},
103
+ })
104
+ return fmt.Errorf("%s", message)
105
+ }
106
+
107
+ if score < p.completeMin {
108
+ message := fmt.Sprintf("confidence %.2f is below completion threshold %.2f", score, p.completeMin)
109
+ markReviewRevise(state, message)
110
+ appendOrReplaceReviewGate(state, models.ValidationResult{
111
+ Name: "review_decision_threshold_met",
112
+ Stage: "review",
113
+ Status: models.ValidationFail,
114
+ Severity: models.SeverityMedium,
115
+ Summary: message,
116
+ ActionableItems: []string{"Retry the patch until confidence reaches the completion threshold or reduce uncertainty in validation/test signals."},
117
+ })
118
+ return nil
119
+ }
120
+
121
+ appendOrReplaceReviewGate(state, models.ValidationResult{
122
+ Name: "review_decision_threshold_met",
123
+ Stage: "review",
124
+ Status: models.ValidationPass,
125
+ Severity: models.SeverityLow,
126
+ Summary: fmt.Sprintf("confidence %.2f meets completion threshold %.2f", score, p.completeMin),
127
+ })
128
+ return nil
129
+ }
130
+
131
+ func appendOrReplaceReviewGate(state *models.RunState, gate models.ValidationResult) {
132
+ if state == nil {
133
+ return
134
+ }
135
+ for i, result := range state.ValidationResults {
136
+ if result.Name == gate.Name && strings.EqualFold(result.Stage, gate.Stage) {
137
+ state.ValidationResults[i] = gate
138
+ return
139
+ }
140
+ }
141
+ state.ValidationResults = append(state.ValidationResults, gate)
142
+ }
143
+
144
+ func markReviewRevise(state *models.RunState, message string) {
145
+ if state == nil {
146
+ return
147
+ }
148
+ if state.Review != nil {
149
+ state.Review.Decision = models.ReviewRevise
150
+ state.Review.Comments = uniqueNonEmptyPolicy(append(state.Review.Comments, message))
151
+ state.Review.Suggestions = uniqueNonEmptyPolicy(append(state.Review.Suggestions, "Increase confidence by improving validation, tests, or review findings before completion."))
152
+ }
153
+ if state.ReviewScorecard != nil {
154
+ state.ReviewScorecard.Decision = models.ReviewRevise
155
+ state.ReviewScorecard.Findings = uniqueNonEmptyPolicy(append(state.ReviewScorecard.Findings, message))
156
+ }
157
+ }
158
+
159
+ func uniqueNonEmptyPolicy(values []string) []string {
160
+ result := make([]string, 0, len(values))
161
+ seen := map[string]struct{}{}
162
+ for _, value := range values {
163
+ trimmed := strings.TrimSpace(value)
164
+ if trimmed == "" {
165
+ continue
166
+ }
167
+ if _, ok := seen[trimmed]; ok {
168
+ continue
169
+ }
170
+ seen[trimmed] = struct{}{}
171
+ result = append(result, trimmed)
172
+ }
173
+ return result
174
+ }
@@ -0,0 +1,71 @@
1
+ package confidence
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/furkanbeydemir/orch/internal/config"
7
+ "github.com/furkanbeydemir/orch/internal/models"
8
+ )
9
+
10
+ func TestPolicyPassesWhenConfidenceMeetsThreshold(t *testing.T) {
11
+ policy := NewPolicy(config.DefaultConfig())
12
+ state := &models.RunState{
13
+ Review: &models.ReviewResult{Decision: models.ReviewAccept},
14
+ ReviewScorecard: &models.ReviewScorecard{Decision: models.ReviewAccept},
15
+ Confidence: &models.ConfidenceReport{Score: 0.82, Band: "medium"},
16
+ }
17
+
18
+ if err := policy.Apply(state); err != nil {
19
+ t.Fatalf("expected policy pass, got error: %v", err)
20
+ }
21
+ if state.Review.Decision != models.ReviewAccept {
22
+ t.Fatalf("expected accept decision to remain")
23
+ }
24
+ if !hasGate(state.ValidationResults, "review_decision_threshold_met", models.ValidationPass) {
25
+ t.Fatalf("expected passing review threshold gate")
26
+ }
27
+ }
28
+
29
+ func TestPolicyRevisesWhenConfidenceBelowCompletionThreshold(t *testing.T) {
30
+ policy := NewPolicy(config.DefaultConfig())
31
+ state := &models.RunState{
32
+ Review: &models.ReviewResult{Decision: models.ReviewAccept},
33
+ ReviewScorecard: &models.ReviewScorecard{Decision: models.ReviewAccept},
34
+ Confidence: &models.ConfidenceReport{Score: 0.61, Band: "low"},
35
+ }
36
+
37
+ if err := policy.Apply(state); err != nil {
38
+ t.Fatalf("expected revise path without hard error, got: %v", err)
39
+ }
40
+ if state.Review.Decision != models.ReviewRevise {
41
+ t.Fatalf("expected review decision to downgrade to revise")
42
+ }
43
+ if !hasGate(state.ValidationResults, "review_decision_threshold_met", models.ValidationFail) {
44
+ t.Fatalf("expected failing review threshold gate")
45
+ }
46
+ }
47
+
48
+ func TestPolicyFailsWhenConfidenceBelowFailThreshold(t *testing.T) {
49
+ policy := NewPolicy(config.DefaultConfig())
50
+ state := &models.RunState{
51
+ Review: &models.ReviewResult{Decision: models.ReviewAccept},
52
+ ReviewScorecard: &models.ReviewScorecard{Decision: models.ReviewAccept},
53
+ Confidence: &models.ConfidenceReport{Score: 0.30, Band: "very_low"},
54
+ }
55
+
56
+ if err := policy.Apply(state); err == nil {
57
+ t.Fatalf("expected hard failure for very low confidence")
58
+ }
59
+ if state.Review.Decision != models.ReviewRevise {
60
+ t.Fatalf("expected decision to downgrade to revise before failure")
61
+ }
62
+ }
63
+
64
+ func hasGate(results []models.ValidationResult, name string, status models.ValidationStatus) bool {
65
+ for _, result := range results {
66
+ if result.Name == name && result.Status == status {
67
+ return true
68
+ }
69
+ }
70
+ return false
71
+ }