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,104 @@
1
+ // Package patch contains patch validation implementation.
2
+ //
3
+ // - Sensitive files are protected.
4
+ package patch
5
+
6
+ import (
7
+ "fmt"
8
+ "path/filepath"
9
+ "strings"
10
+
11
+ "github.com/furkanbeydemir/orch/internal/models"
12
+ )
13
+
14
+ var blockedFiles = []string{
15
+ ".env",
16
+ ".env.local",
17
+ ".env.production",
18
+ "id_rsa",
19
+ "id_ed25519",
20
+ ".pem",
21
+ ".key",
22
+ }
23
+
24
+ var binaryExtensions = []string{
25
+ ".exe", ".dll", ".so", ".dylib",
26
+ ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
27
+ ".zip", ".tar", ".gz", ".rar",
28
+ ".pdf", ".doc", ".docx",
29
+ ".wasm",
30
+ }
31
+
32
+ type Validator struct {
33
+ maxFiles int
34
+ maxLines int
35
+ }
36
+
37
+ func NewValidator(maxFiles, maxLines int) *Validator {
38
+ return &Validator{
39
+ maxFiles: maxFiles,
40
+ maxLines: maxLines,
41
+ }
42
+ }
43
+
44
+ func (v *Validator) Validate(p *models.Patch) error {
45
+ if p == nil {
46
+ return fmt.Errorf("patch cannot be nil")
47
+ }
48
+
49
+ if len(p.Files) > v.maxFiles {
50
+ return fmt.Errorf("patch contains too many files: %d (limit: %d)", len(p.Files), v.maxFiles)
51
+ }
52
+
53
+ lineCount := countLines(p.RawDiff)
54
+ if lineCount > v.maxLines {
55
+ return fmt.Errorf("patch contains too many lines: %d (limit: %d)", lineCount, v.maxLines)
56
+ }
57
+
58
+ for _, file := range p.Files {
59
+ if isBinaryFile(file.Path) {
60
+ return fmt.Errorf("binary file cannot be modified: %s", file.Path)
61
+ }
62
+
63
+ if isBlockedFile(file.Path) {
64
+ return fmt.Errorf("sensitive file cannot be modified: %s", file.Path)
65
+ }
66
+ }
67
+
68
+ return nil
69
+ }
70
+
71
+ func countLines(diff string) int {
72
+ count := 0
73
+ for _, line := range strings.Split(diff, "\n") {
74
+ if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") {
75
+ if !strings.HasPrefix(line, "+++") && !strings.HasPrefix(line, "---") {
76
+ count++
77
+ }
78
+ }
79
+ }
80
+ return count
81
+ }
82
+
83
+ func isBinaryFile(path string) bool {
84
+ ext := strings.ToLower(filepath.Ext(path))
85
+ for _, binExt := range binaryExtensions {
86
+ if ext == binExt {
87
+ return true
88
+ }
89
+ }
90
+ return false
91
+ }
92
+
93
+ func isBlockedFile(path string) bool {
94
+ base := filepath.Base(path)
95
+ for _, blocked := range blockedFiles {
96
+ if strings.EqualFold(base, blocked) {
97
+ return true
98
+ }
99
+ if strings.HasPrefix(blocked, ".") && strings.HasSuffix(strings.ToLower(path), blocked) {
100
+ return true
101
+ }
102
+ }
103
+ return false
104
+ }
@@ -0,0 +1,416 @@
1
+ package planning
2
+
3
+ import (
4
+ "fmt"
5
+ "path/filepath"
6
+ "sort"
7
+ "strings"
8
+
9
+ "github.com/furkanbeydemir/orch/internal/models"
10
+ )
11
+
12
+ var stopWords = map[string]struct{}{
13
+ "add": {}, "the": {}, "and": {}, "for": {}, "with": {}, "into": {}, "from": {},
14
+ "this": {}, "that": {}, "fix": {}, "make": {}, "code": {}, "write": {},
15
+ "update": {}, "service": {}, "feature": {}, "bug": {}, "test": {}, "tests": {},
16
+ }
17
+
18
+ type candidate struct {
19
+ path string
20
+ score int
21
+ }
22
+
23
+ func NormalizeTask(task *models.Task) *models.TaskBrief {
24
+ if task == nil {
25
+ return nil
26
+ }
27
+
28
+ desc := strings.TrimSpace(task.Description)
29
+ lower := strings.ToLower(desc)
30
+ taskType := classifyTaskType(lower)
31
+ risk := classifyRisk(lower, taskType)
32
+
33
+ brief := &models.TaskBrief{
34
+ TaskID: task.ID,
35
+ UserRequest: desc,
36
+ NormalizedGoal: normalizeGoal(desc, taskType),
37
+ TaskType: taskType,
38
+ RiskLevel: risk,
39
+ Constraints: []string{},
40
+ Assumptions: deriveAssumptions(taskType, risk),
41
+ SuccessDefinition: successDefinitionFor(taskType),
42
+ }
43
+
44
+ return brief
45
+ }
46
+
47
+ func CompilePlan(task *models.Task, brief *models.TaskBrief, repoMap *models.RepoMap) *models.Plan {
48
+ if task == nil {
49
+ return nil
50
+ }
51
+ if brief == nil {
52
+ brief = NormalizeTask(task)
53
+ }
54
+
55
+ inspect, modify := rankFiles(task.Description, brief.TaskType, repoMap)
56
+ criteria := acceptanceCriteriaFor(brief)
57
+ testRequirements := testRequirementsFor(brief, repoMap)
58
+ plan := &models.Plan{
59
+ TaskID: task.ID,
60
+ Summary: brief.NormalizedGoal,
61
+ TaskType: brief.TaskType,
62
+ RiskLevel: brief.RiskLevel,
63
+ Steps: buildSteps(brief, inspect, modify),
64
+ FilesToModify: modify,
65
+ FilesToInspect: inspect,
66
+ Risks: risksFor(brief),
67
+ TestStrategy: strings.Join(testRequirements, "; "),
68
+ TestRequirements: testRequirements,
69
+ AcceptanceCriteria: criteria,
70
+ Invariants: invariantsFor(brief),
71
+ ForbiddenChanges: forbiddenChangesFor(brief),
72
+ }
73
+ return plan
74
+ }
75
+
76
+ func classifyTaskType(lower string) models.TaskType {
77
+ switch {
78
+ case containsAny(lower, "race", "bug", "fix", "issue", "error", "regression"):
79
+ return models.TaskTypeBugfix
80
+ case containsAny(lower, "unit test", "integration test", "test ", "tests ", "coverage"):
81
+ return models.TaskTypeTest
82
+ case containsAny(lower, "refactor", "cleanup", "readability", "simplify"):
83
+ return models.TaskTypeRefactor
84
+ case containsAny(lower, "docs", "readme", "documentation"):
85
+ return models.TaskTypeDocs
86
+ case containsAny(lower, "add", "implement", "create", "support", "enable"):
87
+ return models.TaskTypeFeature
88
+ case containsAny(lower, "bump", "upgrade", "rename", "move", "remove", "chore"):
89
+ return models.TaskTypeChore
90
+ default:
91
+ return models.TaskTypeUnknown
92
+ }
93
+ }
94
+
95
+ func classifyRisk(lower string, taskType models.TaskType) models.RiskLevel {
96
+ if containsAny(lower, "race", "concurrency", "auth", "security", "payment", "migration", "schema", "database") {
97
+ return models.RiskHigh
98
+ }
99
+ switch taskType {
100
+ case models.TaskTypeDocs, models.TaskTypeTest:
101
+ return models.RiskLow
102
+ case models.TaskTypeRefactor, models.TaskTypeFeature, models.TaskTypeBugfix:
103
+ return models.RiskMedium
104
+ default:
105
+ return models.RiskMedium
106
+ }
107
+ }
108
+
109
+ func normalizeGoal(desc string, taskType models.TaskType) string {
110
+ trimmed := strings.TrimSpace(desc)
111
+ switch taskType {
112
+ case models.TaskTypeBugfix:
113
+ return fmt.Sprintf("Fix %s while preserving existing behavior.", trimmed)
114
+ case models.TaskTypeTest:
115
+ return fmt.Sprintf("Add or update tests for %s.", trimmed)
116
+ case models.TaskTypeRefactor:
117
+ return fmt.Sprintf("Refactor %s with minimal behavior change.", trimmed)
118
+ case models.TaskTypeDocs:
119
+ return fmt.Sprintf("Update documentation for %s.", trimmed)
120
+ case models.TaskTypeFeature:
121
+ return fmt.Sprintf("Implement %s following existing repository patterns.", trimmed)
122
+ default:
123
+ return fmt.Sprintf("Address task: %s", trimmed)
124
+ }
125
+ }
126
+
127
+ func deriveAssumptions(taskType models.TaskType, risk models.RiskLevel) []string {
128
+ assumptions := []string{"Follow existing repository conventions and keep the diff minimal."}
129
+ if risk == models.RiskHigh {
130
+ assumptions = append(assumptions, "Prefer behavior-preserving changes and protect public interfaces.")
131
+ }
132
+ if taskType == models.TaskTypeTest {
133
+ assumptions = append(assumptions, "Prefer colocated or adjacent test files when possible.")
134
+ }
135
+ return assumptions
136
+ }
137
+
138
+ func successDefinitionFor(taskType models.TaskType) []string {
139
+ switch taskType {
140
+ case models.TaskTypeBugfix:
141
+ return []string{"The reported failure path is addressed.", "Existing behavior outside the bug scope remains unchanged."}
142
+ case models.TaskTypeFeature:
143
+ return []string{"The requested behavior is implemented.", "Relevant tests or verification steps exist."}
144
+ case models.TaskTypeTest:
145
+ return []string{"Relevant tests are added or updated.", "Tests validate the intended behavior."}
146
+ case models.TaskTypeRefactor:
147
+ return []string{"Code structure improves without changing intended behavior.", "Existing tests still pass."}
148
+ case models.TaskTypeDocs:
149
+ return []string{"Documentation reflects the requested behavior accurately."}
150
+ default:
151
+ return []string{"The task is completed with minimal scope change."}
152
+ }
153
+ }
154
+
155
+ func acceptanceCriteriaFor(brief *models.TaskBrief) []models.AcceptanceCriterion {
156
+ if brief == nil {
157
+ return nil
158
+ }
159
+
160
+ descriptions := []string{}
161
+ switch brief.TaskType {
162
+ case models.TaskTypeBugfix:
163
+ descriptions = []string{
164
+ "The original failure condition is no longer reproducible in the intended path.",
165
+ "Existing behavior outside the bug scope remains unchanged.",
166
+ "Relevant regression verification is included or documented.",
167
+ }
168
+ case models.TaskTypeFeature:
169
+ descriptions = []string{
170
+ "The requested behavior is implemented and reachable through the intended code path.",
171
+ "Changes follow existing repository patterns and remain scoped to the task.",
172
+ "Relevant verification or tests are included.",
173
+ }
174
+ case models.TaskTypeTest:
175
+ descriptions = []string{
176
+ "Relevant tests are added or updated.",
177
+ "Tests cover the requested behavior or failure mode.",
178
+ "Production code changes remain minimal unless required for testability.",
179
+ }
180
+ case models.TaskTypeRefactor:
181
+ descriptions = []string{
182
+ "Behavior remains unchanged for supported paths.",
183
+ "The resulting code is easier to follow or maintain.",
184
+ "Existing validation and tests still pass.",
185
+ }
186
+ case models.TaskTypeDocs:
187
+ descriptions = []string{
188
+ "Documentation accurately reflects the requested behavior or workflow.",
189
+ "Examples or instructions remain consistent with the repository.",
190
+ }
191
+ default:
192
+ descriptions = []string{
193
+ "The task goal is addressed with a minimal scoped change.",
194
+ "Relevant verification steps are documented or executed.",
195
+ }
196
+ }
197
+
198
+ criteria := make([]models.AcceptanceCriterion, 0, len(descriptions))
199
+ for i, description := range descriptions {
200
+ criteria = append(criteria, models.AcceptanceCriterion{
201
+ ID: fmt.Sprintf("ac-%d", i+1),
202
+ Description: description,
203
+ })
204
+ }
205
+ return criteria
206
+ }
207
+
208
+ func testRequirementsFor(brief *models.TaskBrief, repoMap *models.RepoMap) []string {
209
+ requirements := []string{"Run the configured test command and confirm no new failures."}
210
+ framework := ""
211
+ if repoMap != nil {
212
+ framework = strings.TrimSpace(repoMap.TestFramework)
213
+ }
214
+ if framework != "" && framework != "unknown" {
215
+ requirements = append(requirements, fmt.Sprintf("Use repository test framework: %s.", framework))
216
+ }
217
+ if brief != nil && brief.TaskType == models.TaskTypeBugfix {
218
+ requirements = append(requirements, "Prefer regression coverage for the reported failure path.")
219
+ }
220
+ if brief != nil && brief.TaskType == models.TaskTypeRefactor {
221
+ requirements = append(requirements, "Verify behavior remains unchanged after refactoring.")
222
+ }
223
+ return requirements
224
+ }
225
+
226
+ func risksFor(brief *models.TaskBrief) []string {
227
+ if brief == nil {
228
+ return nil
229
+ }
230
+ risks := []string{"Scope drift or unrelated edits must be avoided."}
231
+ if brief.RiskLevel == models.RiskHigh {
232
+ risks = append(risks, "High-risk paths require especially careful review and regression validation.")
233
+ }
234
+ switch brief.TaskType {
235
+ case models.TaskTypeBugfix:
236
+ risks = append(risks, "Bug fixes can unintentionally change behavior in adjacent code paths.")
237
+ case models.TaskTypeRefactor:
238
+ risks = append(risks, "Refactors can introduce subtle regressions without obvious API changes.")
239
+ case models.TaskTypeFeature:
240
+ risks = append(risks, "Feature work can expand into unrelated modules if scope is not enforced.")
241
+ }
242
+ return risks
243
+ }
244
+
245
+ func invariantsFor(brief *models.TaskBrief) []string {
246
+ if brief == nil {
247
+ return nil
248
+ }
249
+ invariants := []string{"Do not modify sensitive files or unrelated code paths."}
250
+ switch brief.TaskType {
251
+ case models.TaskTypeBugfix, models.TaskTypeRefactor:
252
+ invariants = append(invariants, "Preserve existing public API behavior unless the task explicitly requires a contract change.")
253
+ case models.TaskTypeTest:
254
+ invariants = append(invariants, "Keep production behavior unchanged unless a minimal testability fix is required.")
255
+ }
256
+ return invariants
257
+ }
258
+
259
+ func forbiddenChangesFor(brief *models.TaskBrief) []string {
260
+ if brief == nil {
261
+ return nil
262
+ }
263
+ forbidden := []string{
264
+ "Do not introduce unrelated refactors.",
265
+ "Do not modify sensitive configuration or secret material.",
266
+ "Do not reformat unrelated files.",
267
+ }
268
+ if brief.TaskType != models.TaskTypeChore {
269
+ forbidden = append(forbidden, "Do not upgrade dependencies unless the task explicitly requires it.")
270
+ }
271
+ return forbidden
272
+ }
273
+
274
+ func buildSteps(brief *models.TaskBrief, inspect, modify []string) []models.PlanStep {
275
+ goal := "the requested task"
276
+ if brief != nil && strings.TrimSpace(brief.NormalizedGoal) != "" {
277
+ goal = brief.NormalizedGoal
278
+ }
279
+
280
+ steps := []models.PlanStep{{
281
+ Order: 1,
282
+ Description: fmt.Sprintf("Inspect the most relevant files and confirm scope for %s", goal),
283
+ }}
284
+ if len(modify) > 0 {
285
+ steps = append(steps, models.PlanStep{
286
+ Order: 2,
287
+ Description: "Implement the smallest possible code change that satisfies the acceptance criteria.",
288
+ TargetFile: modify[0],
289
+ })
290
+ }
291
+ steps = append(steps,
292
+ models.PlanStep{Order: 3, Description: "Validate the resulting patch against scope, safety, and repository conventions."},
293
+ models.PlanStep{Order: 4, Description: "Run the required verification or tests and review the outcome before apply."},
294
+ )
295
+ return steps
296
+ }
297
+
298
+ func rankFiles(description string, taskType models.TaskType, repoMap *models.RepoMap) ([]string, []string) {
299
+ if repoMap == nil || len(repoMap.Files) == 0 {
300
+ return []string{}, []string{}
301
+ }
302
+
303
+ tokens := tokenize(description)
304
+ candidates := make([]candidate, 0, len(repoMap.Files))
305
+ for _, file := range repoMap.Files {
306
+ path := strings.ToLower(file.Path)
307
+ score := scorePath(path, tokens, taskType)
308
+ if score <= 0 {
309
+ continue
310
+ }
311
+ candidates = append(candidates, candidate{path: file.Path, score: score})
312
+ }
313
+
314
+ if len(candidates) == 0 {
315
+ for _, file := range repoMap.Files {
316
+ candidates = append(candidates, candidate{path: file.Path, score: 1})
317
+ }
318
+ }
319
+
320
+ sort.SliceStable(candidates, func(i, j int) bool {
321
+ if candidates[i].score == candidates[j].score {
322
+ return candidates[i].path < candidates[j].path
323
+ }
324
+ return candidates[i].score > candidates[j].score
325
+ })
326
+
327
+ inspect := topUnique(candidates, 6, func(path string) bool { return true })
328
+ modifyFilter := func(path string) bool {
329
+ lower := strings.ToLower(path)
330
+ if taskType == models.TaskTypeTest {
331
+ return isTestPath(lower)
332
+ }
333
+ return !isTestPath(lower) && !isConfigPath(lower)
334
+ }
335
+ modify := topUnique(candidates, 4, modifyFilter)
336
+ if len(modify) == 0 && len(inspect) > 0 {
337
+ modify = append(modify, inspect[0])
338
+ }
339
+ return inspect, modify
340
+ }
341
+
342
+ func topUnique(candidates []candidate, max int, include func(path string) bool) []string {
343
+ result := make([]string, 0, max)
344
+ seen := map[string]struct{}{}
345
+ for _, candidate := range candidates {
346
+ if !include(candidate.path) {
347
+ continue
348
+ }
349
+ if _, ok := seen[candidate.path]; ok {
350
+ continue
351
+ }
352
+ seen[candidate.path] = struct{}{}
353
+ result = append(result, candidate.path)
354
+ if len(result) >= max {
355
+ break
356
+ }
357
+ }
358
+ return result
359
+ }
360
+
361
+ func scorePath(path string, tokens []string, taskType models.TaskType) int {
362
+ score := 0
363
+ base := strings.ToLower(filepath.Base(path))
364
+ for _, token := range tokens {
365
+ if strings.Contains(path, token) {
366
+ score += 3
367
+ }
368
+ if strings.Contains(base, token) {
369
+ score += 2
370
+ }
371
+ }
372
+ if taskType == models.TaskTypeTest && isTestPath(path) {
373
+ score += 4
374
+ }
375
+ if taskType != models.TaskTypeTest && !isTestPath(path) {
376
+ score += 1
377
+ }
378
+ if isConfigPath(path) {
379
+ score--
380
+ }
381
+ return score
382
+ }
383
+
384
+ func tokenize(description string) []string {
385
+ parts := strings.Fields(strings.ToLower(description))
386
+ result := make([]string, 0, len(parts))
387
+ for _, part := range parts {
388
+ cleaned := strings.Trim(part, `"'.,:;()[]{}<>!?`)
389
+ if len(cleaned) < 3 {
390
+ continue
391
+ }
392
+ if _, ok := stopWords[cleaned]; ok {
393
+ continue
394
+ }
395
+ result = append(result, cleaned)
396
+ }
397
+ return result
398
+ }
399
+
400
+ func isTestPath(path string) bool {
401
+ return strings.Contains(path, "_test.") || strings.Contains(path, ".test.") || strings.Contains(path, ".spec.") || strings.Contains(path, "/test") || strings.Contains(path, "\\test")
402
+ }
403
+
404
+ func isConfigPath(path string) bool {
405
+ base := strings.ToLower(filepath.Base(path))
406
+ return strings.Contains(base, "config") || strings.Contains(base, ".env") || base == "package.json" || base == "go.mod" || base == "dockerfile"
407
+ }
408
+
409
+ func containsAny(s string, values ...string) bool {
410
+ for _, value := range values {
411
+ if strings.Contains(s, value) {
412
+ return true
413
+ }
414
+ }
415
+ return false
416
+ }
@@ -0,0 +1,64 @@
1
+ package planning
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/furkanbeydemir/orch/internal/models"
8
+ )
9
+
10
+ func TestNormalizeTaskBugfixClassification(t *testing.T) {
11
+ task := &models.Task{
12
+ ID: "task-1",
13
+ Description: "fix race condition in auth service",
14
+ CreatedAt: time.Now(),
15
+ }
16
+
17
+ brief := NormalizeTask(task)
18
+ if brief == nil {
19
+ t.Fatalf("expected task brief")
20
+ }
21
+ if brief.TaskType != models.TaskTypeBugfix {
22
+ t.Fatalf("unexpected task type: %s", brief.TaskType)
23
+ }
24
+ if brief.RiskLevel != models.RiskHigh {
25
+ t.Fatalf("unexpected risk level: %s", brief.RiskLevel)
26
+ }
27
+ if brief.NormalizedGoal == "" {
28
+ t.Fatalf("expected normalized goal")
29
+ }
30
+ }
31
+
32
+ func TestCompilePlanIncludesAcceptanceCriteriaAndTests(t *testing.T) {
33
+ task := &models.Task{
34
+ ID: "task-2",
35
+ Description: "add redis caching to user service",
36
+ CreatedAt: time.Now(),
37
+ }
38
+ repoMap := &models.RepoMap{
39
+ TestFramework: "go test",
40
+ Files: []models.FileInfo{
41
+ {Path: "internal/user/service.go", Language: "go"},
42
+ {Path: "internal/user/cache.go", Language: "go"},
43
+ {Path: "internal/user/service_test.go", Language: "go"},
44
+ },
45
+ }
46
+
47
+ brief := NormalizeTask(task)
48
+ plan := CompilePlan(task, brief, repoMap)
49
+ if plan == nil {
50
+ t.Fatalf("expected plan")
51
+ }
52
+ if len(plan.AcceptanceCriteria) == 0 {
53
+ t.Fatalf("expected acceptance criteria")
54
+ }
55
+ if len(plan.TestRequirements) == 0 {
56
+ t.Fatalf("expected test requirements")
57
+ }
58
+ if len(plan.FilesToInspect) == 0 {
59
+ t.Fatalf("expected files to inspect")
60
+ }
61
+ if len(plan.Steps) == 0 {
62
+ t.Fatalf("expected plan steps")
63
+ }
64
+ }
@@ -0,0 +1,35 @@
1
+ package providers
2
+
3
+ import "fmt"
4
+
5
+ const (
6
+ ErrAuthError = "provider_auth_error"
7
+ ErrRateLimited = "provider_rate_limited"
8
+ ErrTimeout = "provider_timeout"
9
+ ErrModelUnavailable = "provider_model_unavailable"
10
+ ErrTransient = "provider_transient_error"
11
+ ErrInvalidResponse = "provider_invalid_response"
12
+ )
13
+
14
+ type Error struct {
15
+ Code string
16
+ Message string
17
+ Cause error
18
+ }
19
+
20
+ func (e *Error) Error() string {
21
+ if e == nil {
22
+ return "provider error"
23
+ }
24
+ if e.Cause != nil {
25
+ return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Cause)
26
+ }
27
+ return fmt.Sprintf("%s: %s", e.Code, e.Message)
28
+ }
29
+
30
+ func (e *Error) Unwrap() error {
31
+ if e == nil {
32
+ return nil
33
+ }
34
+ return e.Cause
35
+ }