wydcode 0.4.3

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 (41) hide show
  1. package/LICENSE +100 -0
  2. package/NOTICE +5 -0
  3. package/README.md +434 -0
  4. package/SECURITY.md +64 -0
  5. package/SOCRA_SOURCE.md +36 -0
  6. package/TRADEMARKS.md +12 -0
  7. package/bin/wydcode.js +74 -0
  8. package/examples/behavior-preference-card.json +12 -0
  9. package/examples/exact-recurrence-card.json +11 -0
  10. package/examples/heldout-gene-evidence.json +6 -0
  11. package/examples/humaneval-ornith.md +29 -0
  12. package/examples/large_context_json_diagnostic.py +187 -0
  13. package/fixtures/failing-node-repo/package.json +9 -0
  14. package/fixtures/failing-node-repo/src/math.js +3 -0
  15. package/fixtures/failing-node-repo/test/math.test.js +8 -0
  16. package/package.json +55 -0
  17. package/pyproject.toml +24 -0
  18. package/socra_harness/__init__.py +3 -0
  19. package/socra_harness/attempts.py +76 -0
  20. package/socra_harness/central_traces.py +627 -0
  21. package/socra_harness/cli.py +265 -0
  22. package/socra_harness/eval/__init__.py +1 -0
  23. package/socra_harness/eval/humaneval.py +388 -0
  24. package/socra_harness/events.py +39 -0
  25. package/socra_harness/init.py +206 -0
  26. package/socra_harness/job/__init__.py +2 -0
  27. package/socra_harness/job/events.py +132 -0
  28. package/socra_harness/job/lock.py +90 -0
  29. package/socra_harness/job/manager.py +3785 -0
  30. package/socra_harness/job/planner.py +253 -0
  31. package/socra_harness/job/state.py +106 -0
  32. package/socra_harness/mumpix_trace_bridge.cjs +32 -0
  33. package/socra_harness/product.py +533 -0
  34. package/socra_harness/recurrence.py +263 -0
  35. package/socra_harness/scan/__init__.py +1 -0
  36. package/socra_harness/scan/repo.py +85 -0
  37. package/socra_harness/serve/__init__.py +1 -0
  38. package/socra_harness/serve/local_proxy.py +293 -0
  39. package/socra_harness/start.py +331 -0
  40. package/socra_harness/tui/__init__.py +1 -0
  41. package/socra_harness/tui/app.py +1114 -0
@@ -0,0 +1,533 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import re
7
+ import shlex
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .attempts import detect_interrupted_attempt
12
+ from .central_traces import project_identity, sync_job_traces
13
+ from .init import probe_endpoint, require_git_repo
14
+ from .job.events import JobEventLog, read_events
15
+ from .job.lock import JobLock
16
+ from .job.manager import (
17
+ find_latest_incomplete_job,
18
+ job_promote_main,
19
+ job_reject_promotion_main,
20
+ job_review_promotion_main,
21
+ job_root,
22
+ job_run_milestone_main,
23
+ job_run_next_main,
24
+ new_job_id,
25
+ write_handoff,
26
+ write_json,
27
+ )
28
+ from .job.planner import canonical_task_hash, materialize_plan_from_events, ready_task_ids, validate_task_graph
29
+ from .job.state import replay, state_to_json, write_state_snapshot
30
+ from .recurrence import load_behavior_preference, load_gene_evidence, load_recurrence_card, normalize_repository
31
+
32
+
33
+ def config_path(repo_root: Path) -> Path:
34
+ return repo_root / ".socra" / "config.json"
35
+
36
+
37
+ def load_config(repo_root: Path) -> dict[str, Any]:
38
+ path = config_path(repo_root)
39
+ if not path.exists():
40
+ raise SystemExit(f"Socra config not found: {path}. Run wydcode init first.")
41
+ try:
42
+ config = json.loads(path.read_text(encoding="utf-8"))
43
+ except json.JSONDecodeError as exc:
44
+ raise SystemExit(f"Socra config is invalid JSON: {exc}") from exc
45
+ if not isinstance(config, dict):
46
+ raise SystemExit("Socra config must contain a JSON object.")
47
+ if config.get("schema_version") != 1:
48
+ raise SystemExit(f"Unsupported Socra config schema_version: {config.get('schema_version')}")
49
+ return config
50
+
51
+
52
+ def model_config(config: dict[str, Any]) -> dict[str, Any]:
53
+ model = config.get("model") if isinstance(config.get("model"), dict) else {}
54
+ if not model.get("endpoint"):
55
+ raise SystemExit("Socra config has no model.endpoint. Run wydcode init --endpoint ...")
56
+ return model
57
+
58
+
59
+ def require_live_endpoint(config: dict[str, Any]) -> dict[str, Any]:
60
+ model = model_config(config)
61
+ probe = probe_endpoint(str(model["endpoint"]), timeout=2.0)
62
+ if not probe.get("ok"):
63
+ errors = probe.get("errors") if isinstance(probe.get("errors"), list) else []
64
+ detail = f" Details: {'; '.join(str(item) for item in errors[:2])}" if errors else ""
65
+ raise SystemExit(f"Configured model endpoint is not reachable: {model['endpoint']}.{detail}")
66
+ return probe
67
+
68
+
69
+ SOURCE_SUFFIXES = {".js", ".jsx", ".ts", ".tsx"}
70
+ TEST_NAME_MARKERS = (".test.", ".spec.")
71
+ SOURCE_DIR_PARTS = {"src", "lib", "app", "pages", "components", "packages", "plugins", "plugin"}
72
+ PATH_STOPWORDS = {
73
+ "the",
74
+ "and",
75
+ "for",
76
+ "with",
77
+ "from",
78
+ "into",
79
+ "inside",
80
+ "single",
81
+ "value",
82
+ "nested",
83
+ "partial",
84
+ "updates",
85
+ "update",
86
+ "should",
87
+ "without",
88
+ "instead",
89
+ "removes",
90
+ "other",
91
+ "file",
92
+ "files",
93
+ "bug",
94
+ "fix",
95
+ "test",
96
+ "tests",
97
+ "plugin",
98
+ "src",
99
+ }
100
+
101
+
102
+ def split_identifier(text: str) -> list[str]:
103
+ parts = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text).lower()
104
+ return [token for token in re.split(r"[^a-z0-9]+", parts) if token]
105
+
106
+
107
+ def goal_tokens(goal: str) -> set[str]:
108
+ return {token for token in split_identifier(goal) if len(token) >= 3 and token not in PATH_STOPWORDS}
109
+
110
+
111
+ def goal_terms(goal: str) -> set[str]:
112
+ terms = goal_tokens(goal)
113
+ for raw in re.findall(r"[A-Za-z][A-Za-z0-9]*", goal):
114
+ text = raw.lower()
115
+ if len(text) >= 3 and text not in PATH_STOPWORDS:
116
+ terms.add(text)
117
+ return terms
118
+
119
+
120
+ def source_candidates(repo_root: Path) -> list[Path]:
121
+ candidates: list[Path] = []
122
+ for base in [repo_root / part for part in SOURCE_DIR_PARTS if (repo_root / part).is_dir()]:
123
+ for path in base.rglob("*"):
124
+ if not path.is_file() or path.suffix not in SOURCE_SUFFIXES:
125
+ continue
126
+ if any(marker in path.name for marker in TEST_NAME_MARKERS):
127
+ continue
128
+ rel_parts = set(path.relative_to(repo_root).parts)
129
+ if "node_modules" in rel_parts or ".socra" in rel_parts:
130
+ continue
131
+ candidates.append(path.relative_to(repo_root))
132
+ for name in ["index.js", "main.js", "index.ts", "main.ts"]:
133
+ path = repo_root / name
134
+ if path.is_file():
135
+ candidates.append(path.relative_to(repo_root))
136
+ return sorted(set(candidates), key=lambda item: item.as_posix())
137
+
138
+
139
+ def score_path_against_goal(path: Path, tokens: set[str]) -> tuple[int, list[str]]:
140
+ if not tokens:
141
+ return (0, [])
142
+ parts = path.parts
143
+ normalized_parts = {part.lower() for part in parts}
144
+ stem_tokens = split_identifier(path.stem)
145
+ path_tokens = split_identifier(path.as_posix())
146
+ matches = sorted(tokens.intersection(path_tokens).union(tokens.intersection(normalized_parts)))
147
+ score = 0
148
+ for token in matches:
149
+ score += 8
150
+ if token in stem_tokens:
151
+ score += 14
152
+ if token in normalized_parts:
153
+ score += 4
154
+ if "index" in stem_tokens and len(parts) > 2:
155
+ score += 2
156
+ if path.name in {"index.js", "index.ts"} and len(parts) <= 2:
157
+ score -= 6
158
+ return score, matches
159
+
160
+
161
+ def infer_files_from_goal(repo_root: Path, goal: str) -> list[str]:
162
+ tokens = goal_terms(goal)
163
+ scored = []
164
+ for candidate in source_candidates(repo_root):
165
+ score, matches = score_path_against_goal(candidate, tokens)
166
+ if score > 0:
167
+ scored.append((score, len(matches), -len(candidate.parts), candidate.as_posix(), matches))
168
+ if not scored:
169
+ return []
170
+ scored.sort(reverse=True)
171
+ best = scored[0]
172
+ # Refuse ambiguous guesses instead of spending a model call on the wrong file.
173
+ if len(scored) > 1 and scored[1][0] >= best[0] - 3:
174
+ return []
175
+ if best[0] < 10:
176
+ return []
177
+ return [best[3]]
178
+
179
+
180
+ def infer_toy_source_file(repo_root: Path) -> list[str]:
181
+ preferred = [
182
+ "src/math.js",
183
+ "src/index.js",
184
+ "src/main.js",
185
+ "src/app.js",
186
+ "index.js",
187
+ "main.js",
188
+ ]
189
+ for item in preferred:
190
+ if (repo_root / item).is_file():
191
+ # Keep the original toy fixture ergonomic, but avoid broad-repo first-file guesses.
192
+ candidates = source_candidates(repo_root)
193
+ if len(candidates) <= 3:
194
+ return [item]
195
+ return []
196
+ candidates = source_candidates(repo_root)
197
+ if len(candidates) == 1:
198
+ return [candidates[0].as_posix()]
199
+ return []
200
+
201
+
202
+ def infer_declared_files(repo_root: Path, requested: list[str], goal: str = "") -> list[str]:
203
+ if requested:
204
+ declared = [str(Path(item).as_posix()).lstrip("/") for item in requested]
205
+ else:
206
+ declared = infer_files_from_goal(repo_root, goal)
207
+ if not declared:
208
+ declared = infer_toy_source_file(repo_root)
209
+ if not declared:
210
+ raise SystemExit(
211
+ "Cannot determine a safe source file scope from the bug description. "
212
+ "Pass --declared-file path/to/file."
213
+ )
214
+ if len(declared) > 2:
215
+ raise SystemExit("The first Creator slice accepts at most two --declared-file values.")
216
+ missing = [path for path in declared if not (repo_root / path).is_file()]
217
+ if missing:
218
+ raise SystemExit(f"Declared file does not exist: {', '.join(missing)}")
219
+ return declared
220
+
221
+
222
+ def product_fix_plan(
223
+ goal: str,
224
+ repo_root: Path,
225
+ requested_files: list[str],
226
+ recurrence: dict[str, Any] | None = None,
227
+ retrieval_mode: str = "exact",
228
+ ) -> dict[str, Any]:
229
+ declared_files = infer_declared_files(repo_root, requested_files, goal)
230
+ return {
231
+ "version": 1,
232
+ "goal": goal,
233
+ "source": "product_fix_first_creator_slice",
234
+ "tasks": [
235
+ {
236
+ "id": "task-002",
237
+ "description": goal,
238
+ "acceptance": ["Creator produces a sandbox patch within the declared file set", "Repository test command passes in the staged overlay"],
239
+ "risk_tier": 1,
240
+ "status": "pending",
241
+ "depends_on": [],
242
+ "declared_files": declared_files,
243
+ "attempt_count": 0,
244
+ "produced_artifacts": [],
245
+ "recurrence": recurrence or {
246
+ "status": "recurrence_key_unassignable",
247
+ "reason": "structured_recurrence_card_not_supplied",
248
+ },
249
+ "retrieval_mode": retrieval_mode,
250
+ }
251
+ ],
252
+ }
253
+
254
+
255
+ def latest_job(repo_root: Path) -> Path | None:
256
+ found = find_latest_incomplete_job(repo_root)
257
+ if found:
258
+ return found
259
+ root = job_root(repo_root)
260
+ if not root.exists():
261
+ return None
262
+ candidates = []
263
+ for job_dir in root.iterdir():
264
+ if job_dir.is_dir() and (job_dir / "events.jsonl").exists():
265
+ state = replay(read_events(job_dir / "events.jsonl"))
266
+ candidates.append((state.last_seq, job_dir))
267
+ if not candidates:
268
+ return None
269
+ return sorted(candidates, key=lambda item: item[0], reverse=True)[0][1]
270
+
271
+
272
+ def default_reviewer_name(args: argparse.Namespace) -> str:
273
+ return str(getattr(args, "reviewer_name", None) or getpass.getuser() or "local-user")
274
+
275
+
276
+ def latest_passed_task_id(repo_root: Path, job_id: str | None, requested: str | None = None) -> tuple[str, str]:
277
+ job_dir = job_root(repo_root) / job_id if job_id else latest_job(repo_root)
278
+ if not job_dir:
279
+ raise SystemExit(f"No Socra jobs found under {job_root(repo_root)}")
280
+ events = read_events(job_dir / "events.jsonl")
281
+ plan = materialize_plan_from_events(events, json.loads((job_dir / "plan.json").read_text(encoding="utf-8")) if (job_dir / "plan.json").exists() else {"tasks": []})
282
+ if requested:
283
+ return job_dir.name, requested
284
+ passed = [str(task.get("id")) for task in plan.get("tasks", []) if task.get("status") == "passed"]
285
+ if not passed:
286
+ raise SystemExit("No passed task is available for review/promote.")
287
+ return job_dir.name, passed[-1]
288
+
289
+
290
+ def status_main(args: argparse.Namespace) -> int:
291
+ repo_root = require_git_repo(args.project.expanduser().resolve())
292
+ config = load_config(repo_root)
293
+ print(f"Project: {repo_root}")
294
+ print(f"Config: {config_path(repo_root)}")
295
+ print(f"Tool: {config.get('tool', 'unknown')} schema {config.get('schema_version')}")
296
+ project = config.get("project") if isinstance(config.get("project"), dict) else {}
297
+ print(f"Package manager: {project.get('package_manager') or 'not detected'}")
298
+ test_command = project.get("test_command")
299
+ print(f"Test command: {' '.join(test_command) if isinstance(test_command, list) else 'not detected'}")
300
+ model = config.get("model") if isinstance(config.get("model"), dict) else {}
301
+ print(f"Endpoint: {model.get('endpoint') or 'not configured'}")
302
+ print(f"Model: {model.get('model') or 'not configured'}")
303
+ print(f"Hosted fallback: {bool(model.get('hosted_fallback'))}")
304
+
305
+ job_dir = latest_job(repo_root)
306
+ if not job_dir:
307
+ print("Job: none")
308
+ return 0
309
+
310
+ events = read_events(job_dir / "events.jsonl")
311
+ state = replay(events)
312
+ plan = materialize_plan_from_events(events, json.loads((job_dir / "plan.json").read_text(encoding="utf-8")) if (job_dir / "plan.json").exists() else {"tasks": []})
313
+ print(f"Job: {state.job_id}")
314
+ print(f"Goal: {state.goal}")
315
+ print(f"Phase: {state.phase}")
316
+ print(f"Events: {len(events)}")
317
+ print(f"Active task: {state.active_task_id or 'none'}")
318
+ print(f"Completed tasks: {len(state.completed_tasks)}")
319
+ print(f"Blocked tasks: {len(state.blocked_tasks)}")
320
+ print(f"Ready tasks: {', '.join(ready_task_ids(plan)[:8]) or 'none'}")
321
+ interrupted = detect_interrupted_attempt(events)
322
+ if interrupted:
323
+ print(
324
+ "Interrupted attempt: "
325
+ f"{interrupted['task_id']} after {interrupted['last_event_type']} "
326
+ f"(seq {interrupted['last_event_seq']})"
327
+ )
328
+ if state.last_error:
329
+ print(f"Last error: {state.last_error}")
330
+ return 0
331
+
332
+
333
+ def fix_main(args: argparse.Namespace) -> int:
334
+ repo_root = require_git_repo(args.project.expanduser().resolve())
335
+ config = load_config(repo_root)
336
+ endpoint_probe = {"ok": None, "status": "skipped_plan_only"} if args.plan_only else require_live_endpoint(config)
337
+ job_id = args.job_id or new_job_id(args.goal)
338
+ job_dir = job_root(repo_root) / job_id
339
+ if job_dir.exists():
340
+ raise SystemExit(f"Job already exists: {job_dir}")
341
+
342
+ recurrence = None
343
+ if args.recurrence_card:
344
+ try:
345
+ recurrence = load_recurrence_card(args.recurrence_card.expanduser().resolve())
346
+ repository = project_identity(repo_root).get("repository")
347
+ if not repository or normalize_repository(repository) != recurrence["recurrence_key"]["canonical_repository_identity"]:
348
+ raise ValueError("recurrence card repository does not match the target repository")
349
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
350
+ raise SystemExit(f"Invalid recurrence card: {exc}") from exc
351
+ plan = product_fix_plan(
352
+ args.goal,
353
+ repo_root,
354
+ args.declared_file or [],
355
+ recurrence=recurrence,
356
+ retrieval_mode=args.retrieval,
357
+ )
358
+ validate_task_graph(plan["tasks"])
359
+
360
+ with JobLock(job_dir / ".lock"):
361
+ for folder in ["artifacts", "patches", "sandbox", "checkpoints"]:
362
+ (job_dir / folder).mkdir(parents=True, exist_ok=True)
363
+
364
+ write_json(job_dir / "plan.json", plan)
365
+ log = JobEventLog(job_dir / "events.jsonl", job_id)
366
+ log.append("job_created", {"goal": args.goal, "project": str(repo_root), "source": "product_fix"})
367
+ log.append("config_loaded", {"path": str(config_path(repo_root).relative_to(repo_root)), "schema_version": config.get("schema_version")})
368
+ model = model_config(config)
369
+ budgets = config.get("budgets") if isinstance(config.get("budgets"), dict) else {}
370
+ log.append("creator_endpoint_health_checked", {"endpoint": model.get("endpoint"), "probe": endpoint_probe})
371
+ log.append(
372
+ "creator_configured",
373
+ {
374
+ "role": "creator",
375
+ "model_alias": model.get("model") or "unconfigured",
376
+ "model": model.get("model") or "unconfigured",
377
+ "endpoint": model.get("endpoint"),
378
+ "backend": "openai-compatible",
379
+ "temperature": 0.2,
380
+ "context_budget_tokens": budgets.get("context_budget_tokens"),
381
+ "max_output_tokens": budgets.get("max_output_tokens"),
382
+ "hosted_fallback": bool(model.get("hosted_fallback")),
383
+ "local_first": bool(model.get("local_first", True)),
384
+ "source": "socra_config",
385
+ },
386
+ )
387
+ log.append("plan_written", {"plan": "plan.json", "task_count": len(plan["tasks"])})
388
+ for task in plan["tasks"]:
389
+ log.append("task_added", task)
390
+ log.append(
391
+ "plan_committed",
392
+ {
393
+ "revision": 1,
394
+ "task_count": len(plan["tasks"]),
395
+ "task_hash": canonical_task_hash(plan["tasks"]),
396
+ "source": "product_fix_first_creator_slice",
397
+ },
398
+ )
399
+ if args.plan_only:
400
+ log.append("job_paused", {"reason": "plan_only_no_agent_execution"})
401
+
402
+ events = read_events(job_dir / "events.jsonl")
403
+ state = replay(events)
404
+ write_state_snapshot(job_dir / "job.json", state)
405
+ write_handoff(job_dir, state_to_json(state), plan)
406
+
407
+ print(f"Created fix job: {job_id}")
408
+ print(f"Project: {repo_root}")
409
+ print(f"Job dir: {job_dir}")
410
+ print(f"Phase: {state.phase}")
411
+ if args.plan_only:
412
+ print("Plan-only mode: no endpoint preflight or agent execution was run.")
413
+ print("Next: wydcode status")
414
+ return 0
415
+
416
+ run_code = job_run_next_main(argparse.Namespace(project=repo_root, job_id=job_id, task_id=None, allow_unimplemented=False))
417
+ if run_code != 0:
418
+ try:
419
+ trace_result = sync_job_traces(repo_root, job_dir)
420
+ if trace_result["index_failed"]:
421
+ print(f"Warning: {trace_result['index_failed']} trace event(s) are durable but not indexed; run `wydcode traces sync --project .`")
422
+ except Exception as exc: # noqa: BLE001 - local failure remains authoritative if central export is unavailable.
423
+ print(f"Warning: rejected-attempt trace export failed; run `wydcode traces sync --project .`: {exc}")
424
+ return run_code
425
+
426
+ project = config.get("project") if isinstance(config.get("project"), dict) else {}
427
+ test_command = project.get("test_command")
428
+ if isinstance(test_command, list) and test_command:
429
+ command = " ".join(shlex.quote(str(part)) for part in test_command)
430
+ milestone_code = job_run_milestone_main(
431
+ argparse.Namespace(
432
+ project=repo_root,
433
+ job_id=job_id,
434
+ milestone_id="product-fix-tests",
435
+ command=command,
436
+ timeout_seconds=120,
437
+ prepare_deps="none",
438
+ deps_timeout_seconds=300,
439
+ allow_partial_smoke=True,
440
+ promotion_grade=False,
441
+ )
442
+ )
443
+ if milestone_code != 0:
444
+ try:
445
+ trace_result = sync_job_traces(repo_root, job_dir)
446
+ if trace_result["index_failed"]:
447
+ print(f"Warning: {trace_result['index_failed']} trace event(s) are durable but not indexed; run `wydcode traces sync --project .`")
448
+ except Exception as exc: # noqa: BLE001
449
+ print(f"Warning: rejected-attempt trace export failed; run `wydcode traces sync --project .`: {exc}")
450
+ return milestone_code
451
+ else:
452
+ print("Endpoint preflight: ok")
453
+ print("No test command detected; Creator sandbox attempt completed without repo test gate.")
454
+ print("Next: wydcode status")
455
+ return 0
456
+
457
+
458
+ def review_main(args: argparse.Namespace) -> int:
459
+ repo_root = require_git_repo(args.project.expanduser().resolve())
460
+ load_config(repo_root)
461
+ job_id, task_id = latest_passed_task_id(repo_root, args.job_id, args.task_id)
462
+ return job_review_promotion_main(
463
+ argparse.Namespace(
464
+ project=repo_root,
465
+ job_id=job_id,
466
+ task_id=task_id,
467
+ reviewer_name=default_reviewer_name(args),
468
+ present_diff=True,
469
+ approve_source_promotion=False,
470
+ attest_reviewed=False,
471
+ notes=args.notes,
472
+ )
473
+ )
474
+
475
+
476
+ def approve_main(args: argparse.Namespace) -> int:
477
+ repo_root = require_git_repo(args.project.expanduser().resolve())
478
+ load_config(repo_root)
479
+ job_id, task_id = latest_passed_task_id(repo_root, args.job_id, args.task_id)
480
+ behavior_preference = None
481
+ gene_evidence = None
482
+ if bool(args.behavior_card) != bool(args.heldout_evidence):
483
+ raise SystemExit("--behavior-card and --heldout-evidence must be supplied together")
484
+ if args.behavior_card:
485
+ try:
486
+ behavior_preference = load_behavior_preference(args.behavior_card.expanduser().resolve())
487
+ gene_evidence = load_gene_evidence(args.heldout_evidence.expanduser().resolve())
488
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
489
+ raise SystemExit(f"Invalid behavior or held-out evidence card: {exc}") from exc
490
+ return job_review_promotion_main(
491
+ argparse.Namespace(
492
+ project=repo_root,
493
+ job_id=job_id,
494
+ task_id=task_id,
495
+ reviewer_name=default_reviewer_name(args),
496
+ present_diff=False,
497
+ approve_source_promotion=True,
498
+ attest_reviewed=True,
499
+ notes=args.notes,
500
+ behavior_preference=behavior_preference,
501
+ gene_evidence=gene_evidence,
502
+ )
503
+ )
504
+
505
+
506
+ def reject_main(args: argparse.Namespace) -> int:
507
+ repo_root = require_git_repo(args.project.expanduser().resolve())
508
+ load_config(repo_root)
509
+ job_id, task_id = latest_passed_task_id(repo_root, args.job_id, args.task_id)
510
+ return job_reject_promotion_main(
511
+ argparse.Namespace(
512
+ project=repo_root,
513
+ job_id=job_id,
514
+ task_id=task_id,
515
+ reviewer_name=default_reviewer_name(args),
516
+ reason=args.reason,
517
+ notes=args.notes,
518
+ )
519
+ )
520
+
521
+
522
+ def promote_main(args: argparse.Namespace) -> int:
523
+ repo_root = require_git_repo(args.project.expanduser().resolve())
524
+ load_config(repo_root)
525
+ job_id, task_id = latest_passed_task_id(repo_root, args.job_id, args.task_id)
526
+ return job_promote_main(
527
+ argparse.Namespace(
528
+ project=repo_root,
529
+ job_id=job_id,
530
+ task_id=task_id,
531
+ execute=not args.preflight,
532
+ )
533
+ )