@hunyed15/codecgc 0.1.6 → 0.1.8

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 (67) hide show
  1. package/.claude/hooks/route-edit.ps1 +58 -57
  2. package/INSTALLATION.md +117 -484
  3. package/README.md +118 -150
  4. package/bin/cgc-external-status.js +4 -0
  5. package/bin/cgc-start.js +4 -0
  6. package/bin/codecgc.js +141 -20
  7. package/codecgc/compound/codecgc-capability-matrix.md +37 -0
  8. package/codecgc/reference/README.md +69 -0
  9. package/codecgc/reference/maintainer-guide.md +93 -0
  10. package/codecgc/reference/mcp-tool-surface.md +112 -0
  11. package/codecgc/reference/onboarding.md +69 -0
  12. package/codecgc/reference/operation-guide.md +29 -23
  13. package/codecgc/reference/path-contract.md +53 -0
  14. package/codecgc/reference/policy-routing.md +57 -0
  15. package/codecgc/reference/project-structure.md +80 -0
  16. package/codecgc/reference/quickstart.md +108 -0
  17. package/codecgc/reference/real-workflow-loop.md +123 -0
  18. package/codecgc/reference/recovery-loop.md +109 -0
  19. package/codecgc/reference/release-maintenance-playbook.md +4 -1
  20. package/codecgc/reference/troubleshooting.md +112 -0
  21. package/codecgc/roadmap/codecgc-release-maintenance/delivery-plan.md +49 -0
  22. package/codecgc/roadmap/codecgc-release-maintenance/overview.md +41 -0
  23. package/codecgc/roadmap/codecgc-release-maintenance/phases.md +84 -0
  24. package/codecgcmcp/README.md +57 -11
  25. package/codecgcmcp/src/codecgcmcp/server.py +164 -26
  26. package/codexmcp/src/codexmcp/server.py +32 -26
  27. package/geminimcp/src/geminimcp/server.py +22 -14
  28. package/model-routing.yaml +31 -6
  29. package/package.json +11 -4
  30. package/scripts/audit_codecgc_external_capabilities.py +83 -4
  31. package/scripts/audit_codecgc_historical_audits.py +42 -2
  32. package/scripts/audit_codecgc_package_runtime.py +73 -4
  33. package/scripts/audit_codecgc_release_readiness.py +55 -3
  34. package/scripts/audit_codecgc_workflow_history.py +8 -5
  35. package/scripts/build_codecgc_task.py +62 -45
  36. package/scripts/codecgc_artifact_roots.py +8 -40
  37. package/scripts/codecgc_console_io.py +3 -45
  38. package/scripts/codecgc_executor_registry.py +4 -54
  39. package/scripts/codecgc_path_contract.py +7 -0
  40. package/scripts/codecgc_policy.py +275 -0
  41. package/scripts/codecgc_routing_paths.py +3 -16
  42. package/scripts/codecgc_routing_template.py +11 -135
  43. package/scripts/codecgc_runtime/__init__.py +1 -0
  44. package/scripts/codecgc_runtime/artifacts.py +42 -0
  45. package/scripts/codecgc_runtime/console.py +45 -0
  46. package/scripts/codecgc_runtime/executor_registry.py +55 -0
  47. package/scripts/codecgc_runtime/mcp_config.py +72 -0
  48. package/scripts/codecgc_runtime/path_contract.py +123 -0
  49. package/scripts/codecgc_runtime/paths.py +22 -0
  50. package/scripts/codecgc_runtime/routing_paths.py +16 -0
  51. package/scripts/codecgc_runtime/routing_template.py +169 -0
  52. package/scripts/codecgc_runtime/workflow_runtime.py +72 -0
  53. package/scripts/codecgc_runtime_paths.py +3 -22
  54. package/scripts/codecgc_step_control.py +3 -2
  55. package/scripts/codecgc_workflow_runtime.py +3 -71
  56. package/scripts/entry_codecgc_workflow.py +4 -0
  57. package/scripts/install_codecgc.py +490 -21
  58. package/scripts/normalize_codecgc_audits.py +5 -3
  59. package/scripts/postinstall_codecgc.js +6 -56
  60. package/scripts/review_codecgc_workflow.py +6 -3
  61. package/scripts/route_codecgc_workflow.py +67 -36
  62. package/scripts/run_codecgc_build.py +28 -0
  63. package/scripts/run_codecgc_fix.py +28 -0
  64. package/scripts/run_codecgc_task.py +5 -2
  65. package/scripts/run_codecgc_test.py +28 -0
  66. package/scripts/sync_codecgc_mcp_config.py +4 -54
  67. package/scripts/write_codecgc_review.py +7 -3
@@ -1,13 +1,17 @@
1
1
  import argparse
2
2
  import json
3
3
  import sys
4
- import yaml
5
- from fnmatch import fnmatch
6
4
  from pathlib import Path
7
5
  from typing import Any
8
6
 
7
+ import yaml
8
+
9
9
  from codecgc_console_io import configure_utf8_stdio
10
10
  from codecgc_console_io import print_json
11
+ from codecgc_path_contract import normalize_persisted_project_path
12
+ from codecgc_policy import classify_path
13
+ from codecgc_policy import load_policy
14
+ from codecgc_policy import validate_executor_target
11
15
  from codecgc_routing_paths import resolve_active_routing_file
12
16
  from codecgc_runtime_paths import PACKAGE_ROOT
13
17
  from codecgc_runtime_paths import PROJECT_ROOT
@@ -26,19 +30,7 @@ def normalize_path_text(path_text: str) -> str:
26
30
 
27
31
  def load_simple_routing_config(path: Path) -> dict[str, Any]:
28
32
  """Load routing configuration YAML file using standard yaml library."""
29
- if not path.exists():
30
- raise FileNotFoundError(f"Routing file not found: {path}")
31
-
32
- try:
33
- with open(path, "r", encoding="utf-8") as f:
34
- data = yaml.safe_load(f)
35
-
36
- if not isinstance(data, dict):
37
- raise ValueError(f"Routing config must be a dictionary, got {type(data).__name__}")
38
-
39
- return data
40
- except yaml.YAMLError as e:
41
- raise ValueError(f"Failed to parse routing YAML file {path}: {e}") from e
33
+ return load_policy(path)
42
34
 
43
35
 
44
36
  def load_checklist_yaml(path: Path) -> dict[str, Any]:
@@ -61,9 +53,40 @@ def load_checklist_yaml(path: Path) -> dict[str, Any]:
61
53
  def normalize_string_list(value: Any) -> list[str]:
62
54
  if value is None:
63
55
  return []
56
+ if isinstance(value, str):
57
+ return [item.strip() for item in value.split(",") if item.strip()]
64
58
  if isinstance(value, list):
65
- return [str(item) for item in value]
66
- return [str(value)]
59
+ normalized: list[str] = []
60
+ for item in value:
61
+ if item is None:
62
+ continue
63
+ text = str(item).strip()
64
+ if text:
65
+ normalized.append(text)
66
+ return normalized
67
+ text = str(value).strip()
68
+ return [text] if text else []
69
+
70
+
71
+ def is_executable_codecgc_block(block: Any) -> bool:
72
+ """Compatibility helper for older tests and scripts.
73
+
74
+ Runtime step selection uses codecgc_step_control.is_executable_codecgc_block,
75
+ which is intentionally stricter. This helper preserves the historical task
76
+ builder contract: explicit frontend/backend kind plus at least one target.
77
+ """
78
+ if not isinstance(block, dict):
79
+ return False
80
+
81
+ kind = str(block.get("kind", "")).strip().lower()
82
+ if not kind or kind == "auto":
83
+ return False
84
+
85
+ target_paths = block.get("target_paths", [])
86
+ if not isinstance(target_paths, list):
87
+ return False
88
+
89
+ return any(str(path).strip() for path in target_paths)
67
90
 
68
91
 
69
92
  def resolve_optional_value(cli_value: Any, spec_value: Any, default_value: Any) -> Any:
@@ -140,7 +163,7 @@ def load_checklist_step_payload(args: argparse.Namespace) -> dict[str, Any]:
140
163
  "constraint": constraints,
141
164
  "acceptance": acceptance,
142
165
  "cd": resolve_cd_value(str(resolve_optional_value(args.cd, step_spec.get("cd"), None))),
143
- "routing_file": str(Path(str(args.routing_file)).resolve()),
166
+ "routing_file": normalize_persisted_project_path(args.routing_file),
144
167
  "session_id": str(resolve_optional_value(args.session_id, step_spec.get("session_id"), "")),
145
168
  "model": str(resolve_optional_value(args.model, step_spec.get("model"), "")),
146
169
  "profile": str(resolve_optional_value(args.profile, step_spec.get("profile"), "")),
@@ -160,7 +183,7 @@ def load_checklist_step_payload(args: argparse.Namespace) -> dict[str, Any]:
160
183
  "timeout_seconds": int(step_spec.get("timeout_seconds", 0)) or 0,
161
184
  "source": {
162
185
  "type": "workflow-step",
163
- "artifact_file": str(checklist_path.resolve()),
186
+ "artifact_file": normalize_persisted_project_path(checklist_path),
164
187
  "artifact_type": artifact_type,
165
188
  "artifact_class": artifact_class,
166
189
  "artifact_slug": artifact_slug,
@@ -179,7 +202,7 @@ def load_explicit_task_payload(args: argparse.Namespace) -> dict[str, Any]:
179
202
  "constraint": args.constraint,
180
203
  "acceptance": args.acceptance,
181
204
  "cd": resolve_cd_value(args.cd),
182
- "routing_file": str(Path(str(args.routing_file)).resolve()),
205
+ "routing_file": normalize_persisted_project_path(args.routing_file),
183
206
  "session_id": args.session_id or "",
184
207
  "model": args.model or "",
185
208
  "profile": args.profile or "",
@@ -190,26 +213,6 @@ def load_explicit_task_payload(args: argparse.Namespace) -> dict[str, Any]:
190
213
  }
191
214
 
192
215
 
193
- def classify_path(path_text: str, routing: dict[str, Any]) -> str:
194
- normalized = normalize_path_text(path_text)
195
-
196
- for pattern in routing.get("shared_paths", []):
197
- if fnmatch(normalized, pattern):
198
- return "shared"
199
-
200
- frontend_patterns = list(routing.get("frontend_paths", [])) + list(routing.get("custom_frontend_paths", []))
201
- for pattern in frontend_patterns:
202
- if fnmatch(normalized, pattern):
203
- return "frontend"
204
-
205
- backend_patterns = list(routing.get("backend_paths", [])) + list(routing.get("custom_backend_paths", []))
206
- for pattern in backend_patterns:
207
- if fnmatch(normalized, pattern):
208
- return "backend"
209
-
210
- return "unknown"
211
-
212
-
213
216
  def classify_paths(paths: list[str], routing: dict[str, Any]) -> dict[str, str]:
214
217
  return {path: classify_path(path, routing) for path in paths}
215
218
 
@@ -218,6 +221,10 @@ def split_paths_by_category(classifications: dict[str, str]) -> dict[str, list[s
218
221
  grouped = {
219
222
  "frontend": [],
220
223
  "backend": [],
224
+ "frontend-test": [],
225
+ "backend-test": [],
226
+ "docs": [],
227
+ "orchestration": [],
221
228
  "shared": [],
222
229
  "unknown": [],
223
230
  }
@@ -275,13 +282,15 @@ def detect_target_kind(paths: list[str], routing: dict[str, Any]) -> tuple[str,
275
282
  classifications = classify_paths(paths, routing)
276
283
  categories = set(classifications.values())
277
284
 
278
- if "shared" in categories:
285
+ if categories & {"shared", "docs", "orchestration"}:
279
286
  details = [
280
- f"{path} -> shared" for path, category in classifications.items() if category == "shared"
287
+ f"{path} -> {category}"
288
+ for path, category in classifications.items()
289
+ if category in {"shared", "docs", "orchestration"}
281
290
  ]
282
291
  split_payload = build_split_required_payload(paths, routing)
283
292
  raise ValueError(
284
- "Detected shared paths. Split the task first.\n"
293
+ "Detected non-executor-owned paths. Route them through Claude workflow first.\n"
285
294
  + "\n".join(details)
286
295
  + "\nSPLIT_PAYLOAD: "
287
296
  + json.dumps(split_payload, ensure_ascii=False)
@@ -295,10 +304,10 @@ def detect_target_kind(paths: list[str], routing: dict[str, Any]) -> tuple[str,
295
304
  "Some target paths are not covered by model-routing.yaml.\n" + "\n".join(details)
296
305
  )
297
306
 
298
- if categories == {"frontend"}:
307
+ if categories <= {"frontend", "frontend-test"}:
299
308
  return "frontend", [f"{path} -> frontend" for path in paths]
300
309
 
301
- if categories == {"backend"}:
310
+ if categories <= {"backend", "backend-test"}:
302
311
  return "backend", [f"{path} -> backend" for path in paths]
303
312
 
304
313
  details = [f"{path} -> {classifications[path]}" for path in paths]
@@ -329,6 +338,13 @@ def build_tool_call(args: argparse.Namespace, routing: dict[str, Any]) -> dict[s
329
338
  kind = requested_kind
330
339
  route_notes = [f"{path} -> forced:{kind}" for path in target_paths]
331
340
 
341
+ policy_result = validate_executor_target(kind, target_paths, routing)
342
+ if not policy_result.get("allowed"):
343
+ raise ValueError(
344
+ "Target paths violate CodeCGC routing policy.\n"
345
+ + json.dumps(policy_result, ensure_ascii=False)
346
+ )
347
+
332
348
  if kind == "frontend":
333
349
  tool_name = "implement_frontend_task"
334
350
  tool_args: dict[str, Any] = {
@@ -367,6 +383,7 @@ def build_tool_call(args: argparse.Namespace, routing: dict[str, Any]) -> dict[s
367
383
  "tool_name": tool_name,
368
384
  "tool_args": tool_args,
369
385
  "route_notes": route_notes,
386
+ "policy": policy_result,
370
387
  "routing_file": payload_inputs["routing_file"],
371
388
  }
372
389
  if payload_inputs["source"] is not None:
@@ -1,40 +1,8 @@
1
- from __future__ import annotations
2
-
3
- from codecgc_runtime_paths import PROJECT_ROOT
4
-
5
-
6
- CODECGC_ROOT = PROJECT_ROOT / "codecgc"
7
- PRODUCT_ROOT = CODECGC_ROOT
8
- FIXTURE_ROOT = CODECGC_ROOT / "fixtures"
9
-
10
-
11
- def normalize_artifact_class(value: str | None) -> str:
12
- cleaned = str(value or "product").strip().lower()
13
- return "fixture" if cleaned == "fixture" else "product"
14
-
15
-
16
- def flow_root(flow: str, artifact_class: str) -> Path:
17
- base = FIXTURE_ROOT if normalize_artifact_class(artifact_class) == "fixture" else PRODUCT_ROOT
18
- return base / ("features" if flow == "feature" else "issues")
19
-
20
-
21
- def execution_root(artifact_class: str) -> Path:
22
- base = FIXTURE_ROOT if normalize_artifact_class(artifact_class) == "fixture" else PRODUCT_ROOT
23
- return base / "execution"
24
-
25
-
26
- def discover_flow_directory(flow: str, slug: str, artifact_class: str = "auto") -> tuple[str, Path] | None:
27
- classes = (
28
- [normalize_artifact_class(artifact_class)]
29
- if artifact_class in {"product", "fixture"}
30
- else ["product", "fixture"]
31
- )
32
- for candidate_class in classes:
33
- directory = flow_root(flow, candidate_class) / slug
34
- if directory.exists():
35
- return candidate_class, directory
36
- return None
37
-
38
-
39
- def artifact_output_root(artifact_class: str) -> Path:
40
- return FIXTURE_ROOT if normalize_artifact_class(artifact_class) == "fixture" else PRODUCT_ROOT
1
+ from codecgc_runtime.artifacts import CODECGC_ROOT
2
+ from codecgc_runtime.artifacts import FIXTURE_ROOT
3
+ from codecgc_runtime.artifacts import PRODUCT_ROOT
4
+ from codecgc_runtime.artifacts import artifact_output_root
5
+ from codecgc_runtime.artifacts import discover_flow_directory
6
+ from codecgc_runtime.artifacts import execution_root
7
+ from codecgc_runtime.artifacts import flow_root
8
+ from codecgc_runtime.artifacts import normalize_artifact_class
@@ -1,45 +1,3 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import sys
5
- from typing import Any
6
-
7
-
8
- def configure_utf8_stdio() -> None:
9
- for stream_name in ("stdout", "stderr"):
10
- stream = getattr(sys, stream_name, None)
11
- if stream is None:
12
- continue
13
- reconfigure = getattr(stream, "reconfigure", None)
14
- if callable(reconfigure):
15
- try:
16
- reconfigure(encoding="utf-8", errors="replace")
17
- except Exception:
18
- pass
19
-
20
-
21
- def print_json(payload: dict[str, Any], *, file: Any | None = None) -> None:
22
- target = file or sys.stdout
23
- text = json.dumps(payload, ensure_ascii=False, indent=2)
24
- target.write(text)
25
- target.write("\n")
26
-
27
-
28
- def render_summary_block(title: str, base_lines: list[str], next_actions: list[str]) -> str:
29
- lines = [title, *base_lines]
30
- unique_actions: list[str] = []
31
- seen: set[str] = set()
32
- for item in next_actions:
33
- normalized = str(item).strip()
34
- if not normalized or normalized in seen:
35
- continue
36
- seen.add(normalized)
37
- unique_actions.append(normalized)
38
-
39
- if unique_actions:
40
- lines.append(f"- 下一步: {unique_actions[0]}")
41
- for item in unique_actions[1:]:
42
- lines.append(f"- 备选动作: {item}")
43
- else:
44
- lines.append("- 下一步: 无")
45
- return "\n".join(lines)
1
+ from codecgc_runtime.console import configure_utf8_stdio
2
+ from codecgc_runtime.console import print_json
3
+ from codecgc_runtime.console import render_summary_block
@@ -1,54 +1,4 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import sys
5
- from pathlib import Path
6
- from typing import Any
7
-
8
-
9
- WORKSPACE = Path(__file__).resolve().parents[1]
10
-
11
-
12
- def resolve_python_command() -> str:
13
- override = os.environ.get("CODECGC_PYTHON_COMMAND", "").strip()
14
- if override:
15
- return override
16
- return sys.executable
17
-
18
-
19
- def build_executor_registry() -> dict[str, dict[str, Any]]:
20
- python_command = resolve_python_command()
21
- return {
22
- "backend": {
23
- "target": "backend",
24
- "mcp_server_name": "codex",
25
- "routing_executor": "codexmcp",
26
- "tool_name": "implement_backend_task",
27
- "python_module": "codexmcp.cli",
28
- "pythonpath": str(WORKSPACE / "codexmcp" / "src"),
29
- "python_command": python_command,
30
- },
31
- "frontend": {
32
- "target": "frontend",
33
- "mcp_server_name": "gemini",
34
- "routing_executor": "geminimcp",
35
- "tool_name": "implement_frontend_task",
36
- "python_module": "geminimcp.cli",
37
- "pythonpath": str(WORKSPACE / "geminimcp" / "src"),
38
- "python_command": python_command,
39
- },
40
- }
41
-
42
-
43
- def get_executor_config(target: str) -> dict[str, Any]:
44
- registry = build_executor_registry()
45
- if target not in registry:
46
- raise ValueError(f"Unsupported target: {target}")
47
- return registry[target]
48
-
49
-
50
- def build_executor_env(target: str) -> dict[str, str]:
51
- env = dict(os.environ)
52
- config = get_executor_config(target)
53
- env["PYTHONPATH"] = str(config["pythonpath"])
54
- return env
1
+ from codecgc_runtime.executor_registry import build_executor_env
2
+ from codecgc_runtime.executor_registry import build_executor_registry
3
+ from codecgc_runtime.executor_registry import get_executor_config
4
+ from codecgc_runtime.executor_registry import resolve_python_command
@@ -0,0 +1,7 @@
1
+ from codecgc_runtime.path_contract import is_project_relative_path
2
+ from codecgc_runtime.path_contract import normalize_audit_path_fields
3
+ from codecgc_runtime.path_contract import normalize_path_text
4
+ from codecgc_runtime.path_contract import normalize_persisted_project_path
5
+ from codecgc_runtime.path_contract import normalize_source_contract
6
+ from codecgc_runtime.path_contract import resolve_project_path
7
+ from codecgc_runtime.path_contract import to_project_relative_path
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from fnmatch import fnmatch
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from codecgc_console_io import configure_utf8_stdio
14
+ from codecgc_console_io import print_json
15
+ from codecgc_path_contract import to_project_relative_path
16
+ from codecgc_routing_paths import resolve_active_routing_file
17
+
18
+
19
+ OWNERS = {"orchestration", "docs", "frontend", "backend", "frontend-test", "backend-test", "shared", "unknown"}
20
+ POLICY_PROJECT_ROOT_KEY = "_codecgc_project_root"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class PathDecision:
25
+ path: str
26
+ owner: str
27
+ allowed: bool
28
+ reason: str
29
+ recommended_action: str
30
+
31
+
32
+ def normalize_path_text(path_text: str) -> str:
33
+ normalized = str(path_text or "").replace("\\", "/").strip()
34
+ while normalized.startswith("./"):
35
+ normalized = normalized[2:]
36
+ return normalized
37
+
38
+
39
+ def _as_string_list(value: Any) -> list[str]:
40
+ if value is None:
41
+ return []
42
+ if isinstance(value, list):
43
+ return [str(item).strip() for item in value if str(item).strip()]
44
+ return [str(value).strip()] if str(value).strip() else []
45
+
46
+
47
+ def _load_yaml(path: Path) -> dict[str, Any]:
48
+ if not path.exists():
49
+ raise FileNotFoundError(f"Routing policy file not found: {path}")
50
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
51
+ if not isinstance(data, dict):
52
+ raise ValueError(f"Routing policy must be a mapping: {path}")
53
+ return data
54
+
55
+
56
+ def load_policy(path: Path | None = None) -> dict[str, Any]:
57
+ policy_path = path or resolve_active_routing_file()
58
+ policy = _load_yaml(policy_path)
59
+ if int(policy.get("version", 0) or 0) != 2:
60
+ raise ValueError("model-routing.yaml must use version: 2.")
61
+ validate_policy(policy)
62
+ policy[POLICY_PROJECT_ROOT_KEY] = str(policy_path.parent.resolve())
63
+ return policy
64
+
65
+
66
+ def validate_policy(policy: dict[str, Any]) -> None:
67
+ required_lists = [
68
+ "orchestration_paths",
69
+ "docs_paths",
70
+ "frontend_paths",
71
+ "backend_paths",
72
+ "shared_paths",
73
+ ]
74
+ missing = [name for name in required_lists if not isinstance(policy.get(name), list)]
75
+ test_paths = policy.get("test_paths")
76
+ rules = policy.get("rules")
77
+ if not isinstance(test_paths, dict):
78
+ missing.append("test_paths")
79
+ if not isinstance(rules, dict):
80
+ missing.append("rules")
81
+ if missing:
82
+ raise ValueError(f"model-routing.yaml is missing required policy sections: {', '.join(missing)}")
83
+
84
+ for name in ("frontend", "backend"):
85
+ if not isinstance(test_paths.get(name), list):
86
+ raise ValueError(f"model-routing.yaml test_paths.{name} must be a list.")
87
+
88
+ allowed = set(_as_string_list(rules.get("claude_allowed_owners")))
89
+ invalid_allowed = sorted(allowed - OWNERS)
90
+ if invalid_allowed:
91
+ raise ValueError(f"Invalid claude_allowed_owners entries: {', '.join(invalid_allowed)}")
92
+
93
+ if str(rules.get("shared_policy", "")).strip() != "split-first":
94
+ raise ValueError("Only shared_policy: split-first is supported.")
95
+
96
+
97
+ def _matches_any(path_text: str, patterns: list[str]) -> bool:
98
+ return any(fnmatch(path_text, pattern) for pattern in patterns)
99
+
100
+
101
+ def normalize_policy_path(path_text: str, policy: dict[str, Any]) -> str:
102
+ project_root = str(policy.get(POLICY_PROJECT_ROOT_KEY, "")).strip()
103
+ if project_root:
104
+ return normalize_path_text(
105
+ to_project_relative_path(path_text, Path(project_root), allow_legacy_suffix=False)
106
+ )
107
+ return normalize_path_text(path_text)
108
+
109
+
110
+ def classify_path(path_text: str, policy: dict[str, Any]) -> str:
111
+ normalized = normalize_policy_path(path_text, policy)
112
+ test_paths = policy.get("test_paths", {})
113
+
114
+ ordered_groups: list[tuple[str, list[str]]] = [
115
+ ("shared", _as_string_list(policy.get("shared_paths"))),
116
+ ("orchestration", _as_string_list(policy.get("orchestration_paths"))),
117
+ ("docs", _as_string_list(policy.get("docs_paths"))),
118
+ ("frontend-test", _as_string_list(test_paths.get("frontend"))),
119
+ ("backend-test", _as_string_list(test_paths.get("backend"))),
120
+ ("frontend", _as_string_list(policy.get("frontend_paths"))),
121
+ ("backend", _as_string_list(policy.get("backend_paths"))),
122
+ ]
123
+ for owner, patterns in ordered_groups:
124
+ if _matches_any(normalized, patterns):
125
+ return owner
126
+ return "unknown"
127
+
128
+
129
+ def allowed_owners_for_actor(actor: str, policy: dict[str, Any]) -> set[str]:
130
+ normalized_actor = str(actor or "").strip().lower()
131
+ rules = policy.get("rules", {})
132
+ if normalized_actor == "claude":
133
+ return set(_as_string_list(rules.get("claude_allowed_owners")))
134
+ if normalized_actor in {"codex", "codexmcp", "backend"}:
135
+ return {"backend", "backend-test"}
136
+ if normalized_actor in {"gemini", "geminimcp", "frontend"}:
137
+ return {"frontend", "frontend-test"}
138
+ return set()
139
+
140
+
141
+ def recommended_action_for(owner: str, actor: str) -> str:
142
+ if owner == "backend":
143
+ return "route through cgc-build or cgc-fix with a backend step"
144
+ if owner == "backend-test":
145
+ return "route through cgc-test with a backend test step"
146
+ if owner == "frontend":
147
+ return "route through cgc-build or cgc-fix with a frontend step"
148
+ if owner == "frontend-test":
149
+ return "route through cgc-test with a frontend test step"
150
+ if owner == "shared":
151
+ return "split the shared change before execution"
152
+ if owner == "unknown":
153
+ return "add the path to model-routing.yaml or narrow the target path"
154
+ return f"request a policy route for actor {actor}"
155
+
156
+
157
+ def decide_path(path_text: str, actor: str, operation: str, policy: dict[str, Any]) -> PathDecision:
158
+ owner = classify_path(path_text, policy)
159
+ allowed_owners = allowed_owners_for_actor(actor, policy)
160
+ normalized_actor = str(actor or "").strip().lower()
161
+ normalized_operation = str(operation or "").strip().lower() or "write"
162
+
163
+ allowed = normalized_operation in {"read", "inspect"} or owner in allowed_owners
164
+ if owner == "unknown":
165
+ allowed = False
166
+ if owner == "shared" and normalized_operation not in {"read", "inspect"}:
167
+ allowed = False
168
+
169
+ if allowed:
170
+ reason = f"{normalized_actor} may {normalized_operation} {owner} paths"
171
+ recommended = ""
172
+ elif owner == "shared":
173
+ reason = "shared paths require split-first routing"
174
+ recommended = recommended_action_for(owner, normalized_actor)
175
+ elif owner == "unknown":
176
+ reason = "path is not covered by model-routing.yaml"
177
+ recommended = recommended_action_for(owner, normalized_actor)
178
+ else:
179
+ reason = f"{owner} paths are not owned by {normalized_actor}"
180
+ recommended = recommended_action_for(owner, normalized_actor)
181
+
182
+ return PathDecision(
183
+ path=normalize_policy_path(path_text, policy),
184
+ owner=owner,
185
+ allowed=allowed,
186
+ reason=reason,
187
+ recommended_action=recommended,
188
+ )
189
+
190
+
191
+ def evaluate_paths(paths: list[str], actor: str, operation: str, policy: dict[str, Any]) -> dict[str, Any]:
192
+ decisions = [decide_path(path, actor, operation, policy) for path in paths]
193
+ return {
194
+ "success": all(decision.allowed for decision in decisions),
195
+ "allowed": all(decision.allowed for decision in decisions),
196
+ "actor": str(actor or "").strip().lower(),
197
+ "operation": str(operation or "").strip().lower() or "write",
198
+ "decisions": [decision.__dict__ for decision in decisions],
199
+ "owners": sorted({decision.owner for decision in decisions}),
200
+ }
201
+
202
+
203
+ def validate_executor_target(kind: str, target_paths: list[str], policy: dict[str, Any]) -> dict[str, Any]:
204
+ actor = "codex" if str(kind).strip().lower() == "backend" else "gemini" if str(kind).strip().lower() == "frontend" else str(kind)
205
+ result = evaluate_paths(target_paths, actor=actor, operation="write", policy=policy)
206
+ result["kind"] = str(kind).strip().lower()
207
+ return result
208
+
209
+
210
+ def parse_hook_payload(text: str) -> tuple[str, str]:
211
+ if not text.strip():
212
+ return "", ""
213
+ payload = json.loads(text)
214
+ if not isinstance(payload, dict):
215
+ return "", ""
216
+ tool_name = str(payload.get("tool_name", "")).strip()
217
+ tool_input = payload.get("tool_input", {})
218
+ if not isinstance(tool_input, dict):
219
+ return tool_name, ""
220
+ file_path = str(tool_input.get("file_path") or tool_input.get("path") or "").strip()
221
+ return tool_name, file_path
222
+
223
+
224
+ def build_hook_response(allowed: bool, reason: str) -> dict[str, Any]:
225
+ if allowed:
226
+ return {"decision": "approve"}
227
+ return {"decision": "deny", "reason": reason}
228
+
229
+
230
+ def build_parser() -> argparse.ArgumentParser:
231
+ parser = argparse.ArgumentParser(description="Evaluate CodeCGC routing policy decisions.")
232
+ parser.add_argument("--routing-file", default="", help="Optional explicit model-routing.yaml path.")
233
+ parser.add_argument("--actor", default="claude", help="Actor requesting the operation.")
234
+ parser.add_argument("--operation", default="write", help="Operation to evaluate.")
235
+ parser.add_argument("--path", action="append", default=[], help="Path to evaluate. Repeatable.")
236
+ parser.add_argument("--hook-check", action="store_true", help="Read Claude hook JSON from stdin and return hook JSON.")
237
+ return parser
238
+
239
+
240
+ def main() -> int:
241
+ configure_utf8_stdio()
242
+ parser = build_parser()
243
+ args = parser.parse_args()
244
+
245
+ try:
246
+ policy_path = Path(args.routing_file).resolve() if args.routing_file else resolve_active_routing_file()
247
+ policy = load_policy(policy_path)
248
+
249
+ if args.hook_check:
250
+ tool_name, file_path = parse_hook_payload(sys.stdin.read())
251
+ if tool_name not in {"Edit", "Write", "MultiEdit"} or not file_path:
252
+ print_json(build_hook_response(True, ""))
253
+ return 0
254
+ result = evaluate_paths([file_path], actor=args.actor, operation=args.operation, policy=policy)
255
+ decision = result["decisions"][0]
256
+ reason = decision.get("reason", "")
257
+ recommended = decision.get("recommended_action", "")
258
+ hook_reason = f"CodeCGC: {reason}."
259
+ if recommended:
260
+ hook_reason += f" {recommended}."
261
+ print_json(build_hook_response(bool(result["allowed"]), hook_reason))
262
+ return 0
263
+
264
+ if not args.path:
265
+ raise ValueError("At least one --path is required unless --hook-check is used.")
266
+
267
+ print_json(evaluate_paths(args.path, actor=args.actor, operation=args.operation, policy=policy))
268
+ return 0
269
+ except Exception as error:
270
+ print_json({"success": False, "allowed": False, "error": str(error)}, file=sys.stderr)
271
+ return 1
272
+
273
+
274
+ if __name__ == "__main__":
275
+ raise SystemExit(main())
@@ -1,16 +1,3 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- from codecgc_runtime_paths import PACKAGE_ROOT
6
- from codecgc_runtime_paths import PROJECT_ROOT
7
-
8
-
9
- PACKAGE_ROUTING_FILE = PACKAGE_ROOT / "model-routing.yaml"
10
- PROJECT_ROUTING_FILE = PROJECT_ROOT / "model-routing.yaml"
11
-
12
-
13
- def resolve_active_routing_file() -> Path:
14
- if PROJECT_ROUTING_FILE.exists():
15
- return PROJECT_ROUTING_FILE
16
- return PACKAGE_ROUTING_FILE
1
+ from codecgc_runtime.routing_paths import PACKAGE_ROUTING_FILE
2
+ from codecgc_runtime.routing_paths import PROJECT_ROUTING_FILE
3
+ from codecgc_runtime.routing_paths import resolve_active_routing_file