@hunyed15/codecgc 0.1.8 → 0.1.10

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.
package/INSTALLATION.md CHANGED
@@ -48,11 +48,16 @@ cgc-install --mode local --workspace .
48
48
  .mcp.json
49
49
  model-routing.yaml
50
50
  .claude/
51
- settings.json
51
+ settings.local.json
52
52
  hooks/
53
53
  route-edit.ps1
54
54
  commands/
55
55
  cgc*.md
56
+ .codex/
57
+ codecgcrc.json
58
+ .gemini/
59
+ policies/
60
+ codecgc-policy.toml
56
61
  codecgc/
57
62
  START_HERE.md
58
63
  features/
package/README.md CHANGED
@@ -14,6 +14,8 @@ Claude /cgc -> CodeCGC MCP -> CodeCGC runtime -> Codex 或 Gemini 执行器
14
14
 
15
15
  CLI 仍然保留,用于本地调试、CI 检查和 MCP 不可用时的回退执行。普通用户优先使用 Claude 内的 `/cgc`,或在命令行使用 `cgc`,不需要记住所有内部子命令。
16
16
 
17
+ 安全边界:Claude 可以维护需求、规划、文档、审查和工作流状态;产品代码实现必须经由 CodeCGC 路由到 Codex 或 Gemini。项目 hook 会拦截 `Edit`、`Write`、`MultiEdit`、`Bash` 和 `PowerShell`,防止 Claude 用直接编辑或 shell 写入绕过路由。
18
+
17
19
  ## 安装
18
20
 
19
21
  全局安装 CLI:
@@ -53,11 +55,16 @@ cgc-doctor
53
55
  .mcp.json
54
56
  model-routing.yaml
55
57
  .claude/
56
- settings.json
58
+ settings.local.json
57
59
  hooks/
58
60
  route-edit.ps1
59
61
  commands/
60
62
  cgc*.md
63
+ .codex/
64
+ codecgcrc.json
65
+ .gemini/
66
+ policies/
67
+ codecgc-policy.toml
61
68
  codecgc/
62
69
  START_HERE.md
63
70
  features/
@@ -72,7 +79,7 @@ codecgc/
72
79
  fixtures/
73
80
  ```
74
81
 
75
- 在 CodeCGC 源码仓库中,`.mcp.json`、`.claude/settings.json`、`.claude/commands/`、`codecgc/START_HERE.md` 以及实时 workflow 输出目录会被忽略,因为它们是机器相关或项目安装生成的内容。
82
+ 在 CodeCGC 源码仓库中,`.mcp.json`、`.claude/settings.local.json`、`.claude/commands/`、`.codex/`、`.gemini/`、`codecgc/START_HERE.md` 以及实时 workflow 输出目录会被忽略,因为它们是机器相关或项目安装生成的内容。
76
83
 
77
84
  源码仓库会保留可发布运行时、参考文档、命令模板、测试 fixtures,以及 `.claude/hooks/route-edit.ps1` 这个 hook 模板。
78
85
 
@@ -149,7 +156,7 @@ python scripts\audit_codecgc_release_readiness.py --format json
149
156
  npm pack --dry-run --json
150
157
  ```
151
158
 
152
- `cgc-release-readiness` 会通过临时项目安装探针验证发布包可用性。源码仓库本身不需要提交项目级 `.mcp.json` 或 `.claude/settings.json`。
159
+ `cgc-release-readiness` 会通过临时项目安装探针验证发布包可用性。源码仓库本身不需要提交项目级 `.mcp.json`、`.claude/settings.local.json`、`.codex/` 或 `.gemini/`。
153
160
 
154
161
  如果运行环境限制默认临时目录写入,可以显式指定探针目录:
155
162
 
@@ -44,7 +44,9 @@ CodeCGC 把“工作流控制”和“代码执行”明确分层。
44
44
  运行时 guardrail 主要声明在:
45
45
 
46
46
  - `.mcp.json`
47
- - `.claude/settings.json`
47
+ - `.claude/settings.local.json`
48
+ - `.codex/codecgcrc.json`
49
+ - `.gemini/policies/codecgc-policy.toml`
48
50
  - `.claude/hooks/route-edit.ps1`
49
51
 
50
52
  这些文件一起负责“不能越界”。
@@ -44,11 +44,12 @@ This creates project-local `.mcp.json`, `.claude/`, `model-routing.yaml`, and th
44
44
  `scripts/codecgc_policy.py` evaluates the policy for every entry point that needs write ownership:
45
45
 
46
46
  - Claude hook guardrail via `.claude/hooks/route-edit.ps1` for `Edit`, `Write`, and `MultiEdit`
47
+ - Claude shell guardrail via the same hook for `Bash` and `PowerShell`
47
48
  - task payload construction via `scripts/build_codecgc_task.py`
48
49
  - build/fix/test wrappers before executor dispatch
49
50
  - status and doctor checks through `model-routing.yaml` validation
50
51
 
51
- The hook is intentionally thin. It only forwards Claude edit requests to the shared policy checker.
52
+ The hook is intentionally thin. It forwards Claude edit requests to the shared policy checker, and it only allows Claude shell commands that are CodeCGC entry commands, read-only inspection commands, or test/check commands. Direct shell writes, destructive commands, redirection, pipes, and chained shell commands are denied so Claude cannot bypass CodeCGC routing.
52
53
 
53
54
  ## Shared Paths
54
55
 
@@ -29,8 +29,10 @@ The source repository should not commit project-local install output. These file
29
29
 
30
30
  ```text
31
31
  .mcp.json
32
- .claude/settings.json
32
+ .claude/settings.local.json
33
33
  .claude/commands/
34
+ .codex/codecgcrc.json
35
+ .gemini/policies/codecgc-policy.toml
34
36
  codecgc/START_HERE.md
35
37
  codecgc/features/
36
38
  codecgc/issues/
@@ -52,11 +54,16 @@ Keep `codecgc/fixtures/`, `codecgc/reference/`, `codecgc/roadmap/`, and `codecgc
52
54
  .mcp.json
53
55
  model-routing.yaml
54
56
  .claude/
55
- settings.json
57
+ settings.local.json
56
58
  hooks/
57
59
  route-edit.ps1
58
60
  commands/
59
61
  cgc*.md
62
+ .codex/
63
+ codecgcrc.json
64
+ .gemini/
65
+ policies/
66
+ codecgc-policy.toml
60
67
  codecgc/
61
68
  features/
62
69
  issues/
@@ -31,9 +31,11 @@ The project install syncs:
31
31
  ```text
32
32
  .mcp.json
33
33
  model-routing.yaml
34
- .claude/settings.json
34
+ .claude/settings.local.json
35
35
  .claude/hooks/route-edit.ps1
36
36
  .claude/commands/cgc*.md
37
+ .codex/codecgcrc.json
38
+ .gemini/policies/codecgc-policy.toml
37
39
  codecgc/START_HERE.md
38
40
  codecgc/
39
41
  ```
@@ -45,7 +45,7 @@ The generated file is `codecgc/START_HERE.md`. It should use project-relative pa
45
45
 
46
46
  ## Claude Hook Blocks A Write
47
47
 
48
- The hook is a guardrail. It blocks Claude direct writes to product source paths that should belong to Codex or Gemini.
48
+ The hook is a guardrail. It blocks Claude direct writes to product source paths that should belong to Codex or Gemini, including direct shell writes through `Bash` or `PowerShell`.
49
49
 
50
50
  Expected behavior:
51
51
 
@@ -56,6 +56,8 @@ Expected behavior:
56
56
 
57
57
  If a write was blocked incorrectly, inspect `model-routing.yaml` first. It is the project-local policy source.
58
58
 
59
+ For shell commands, use `/cgc` or `cgc` for implementation work. The hook only allows CodeCGC entry commands, read-only inspection commands, and test/check commands.
60
+
59
61
  ## Codex Or Gemini Is Unavailable
60
62
 
61
63
  Run:
@@ -0,0 +1,27 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebSearch",
5
+ "Read(**)",
6
+ "Edit(**)",
7
+ "Update(**)",
8
+ "Write(**)",
9
+ "mcp__*",
10
+ "mcp__codecgc__*",
11
+ "mcp__codex__*",
12
+ "mcp__gemini__*",
13
+ "Bash(*)",
14
+ "PowerShell(*)",
15
+ "Edit *",
16
+ "Reading *",
17
+ "Added *",
18
+ "mcp__memos-mcp__add_message"
19
+ ]
20
+ },
21
+ "enabledMcpjsonServers": [
22
+ "codex",
23
+ "gemini",
24
+ "codecgc"
25
+ ],
26
+ "enableAllProjectMcpServers": true
27
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "schema": 1,
3
+ "owner": "CodeCGC",
4
+ "scope": "project",
5
+ "executor": "codex",
6
+ "role": "backend implementation and backend tests",
7
+ "enforcement": {
8
+ "primary": "CodeCGC MCP backend path validation",
9
+ "codex_cli": "sandbox and approval flags supplied by codexmcp",
10
+ "routing_policy": "model-routing.yaml"
11
+ },
12
+ "allowed_path_kinds": [
13
+ "backend"
14
+ ],
15
+ "denied_path_kinds": [
16
+ "frontend"
17
+ ],
18
+ "notes": [
19
+ "Codex CLI project execpolicy discovery is version dependent; CodeCGC treats this file as the project-local policy contract used by its MCP wrapper and status checks.",
20
+ "Do not edit frontend files, styling files, UI components, or mixed shared paths without a split plan."
21
+ ]
22
+ }
@@ -0,0 +1,87 @@
1
+ # CodeCGC project-local Gemini policy.
2
+ #
3
+ # This file is passed explicitly by GeminiMCP through `gemini --policy`.
4
+ # It is intentionally project-local so each repository can review and tune it.
5
+ #
6
+ # In yolo approval mode, all tools are auto-approved by default.
7
+ # These DENY rules act as safety guardrails to block dangerous operations.
8
+
9
+ # --- DENY: destructive shell commands (highest priority) ---
10
+ [[rule]]
11
+ toolName = "run_shell_command"
12
+ commandPrefix = [
13
+ "rm -rf",
14
+ "del /",
15
+ "del \\",
16
+ "rmdir /s",
17
+ "rmdir ",
18
+ "Remove-Item",
19
+ "git reset --hard",
20
+ "git clean",
21
+ "git push --force",
22
+ "git push -f",
23
+ "format ",
24
+ "shutdown",
25
+ "reboot",
26
+ "taskkill",
27
+ "net stop",
28
+ "net user",
29
+ "reg delete",
30
+ "reg add",
31
+ ]
32
+ decision = "deny"
33
+ priority = 900
34
+ denyMessage = "CodeCGC blocks destructive shell commands in Gemini executor sessions."
35
+
36
+ # --- DENY: network / exfiltration commands ---
37
+ [[rule]]
38
+ toolName = "run_shell_command"
39
+ commandPrefix = [
40
+ "curl ",
41
+ "wget ",
42
+ "Invoke-WebRequest",
43
+ "Invoke-RestMethod",
44
+ "ssh ",
45
+ "scp ",
46
+ ]
47
+ decision = "deny"
48
+ priority = 800
49
+ denyMessage = "CodeCGC blocks network commands in Gemini executor sessions."
50
+
51
+ # --- ALLOW: file editing tools ---
52
+ [[rule]]
53
+ toolName = [
54
+ "write_file",
55
+ "replace"
56
+ ]
57
+ decision = "allow"
58
+ priority = 500
59
+
60
+ # --- ALLOW: common dev commands (defense-in-depth) ---
61
+ [[rule]]
62
+ toolName = "run_shell_command"
63
+ commandPrefix = [
64
+ "npm test",
65
+ "npm run",
66
+ "npm install",
67
+ "npx ",
68
+ "pnpm ",
69
+ "yarn ",
70
+ "node ",
71
+ "tsc",
72
+ "eslint",
73
+ "prettier",
74
+ "git diff",
75
+ "git status",
76
+ "git log",
77
+ "cat ",
78
+ "type ",
79
+ "ls ",
80
+ "dir ",
81
+ "head ",
82
+ "tail ",
83
+ "find ",
84
+ "grep ",
85
+ ]
86
+ decision = "allow"
87
+ priority = 300
@@ -51,6 +51,8 @@ FRONTEND_FILE_SUFFIXES = (
51
51
  ".svelte",
52
52
  )
53
53
 
54
+ PROJECT_CODEX_POLICY_RELATIVE_PATH = Path(".codex") / "codecgcrc.json"
55
+
54
56
 
55
57
  def _empty_str_to_none(value: str | None) -> str | None:
56
58
  """Convert empty strings to None for optional UUID parameters."""
@@ -64,6 +66,45 @@ def _normalize_path_text(path_value: Path | str) -> str:
64
66
  return str(path_value).replace("\\", "/").strip()
65
67
 
66
68
 
69
+ def _load_project_codex_policy_context(cd: Path) -> str:
70
+ """Load the CodeCGC project-local Codex policy contract as prompt context."""
71
+ policy_path = cd / PROJECT_CODEX_POLICY_RELATIVE_PATH
72
+ if not policy_path.is_file():
73
+ return ""
74
+
75
+ try:
76
+ payload = json.loads(policy_path.read_text(encoding="utf-8"))
77
+ except Exception:
78
+ return ""
79
+
80
+ if not isinstance(payload, dict):
81
+ return ""
82
+
83
+ enforcement = payload.get("enforcement")
84
+ if not isinstance(enforcement, dict):
85
+ enforcement = {}
86
+
87
+ allowed_path_kinds = payload.get("allowed_path_kinds")
88
+ denied_path_kinds = payload.get("denied_path_kinds")
89
+ notes = payload.get("notes")
90
+
91
+ lines = [
92
+ "Project CodeCGC Codex policy contract:",
93
+ f"- Role: {payload.get('role', 'backend implementation and backend tests')}",
94
+ f"- Routing policy: {enforcement.get('routing_policy', 'model-routing.yaml')}",
95
+ f"- Primary enforcement: {enforcement.get('primary', 'CodeCGC MCP backend path validation')}",
96
+ f"- Codex CLI guardrail: {enforcement.get('codex_cli', 'sandbox and approval flags supplied by codexmcp')}",
97
+ ]
98
+ if isinstance(allowed_path_kinds, list):
99
+ lines.append("- Allowed path kinds: " + ", ".join(str(item) for item in allowed_path_kinds))
100
+ if isinstance(denied_path_kinds, list):
101
+ lines.append("- Denied path kinds: " + ", ".join(str(item) for item in denied_path_kinds))
102
+ if isinstance(notes, list):
103
+ lines.extend(f"- {str(item)}" for item in notes if str(item).strip())
104
+ lines.append("Follow this project policy before making any code change.")
105
+ return "\n".join(lines).strip()
106
+
107
+
67
108
  def _is_probably_frontend_path(path_value: Path | str) -> bool:
68
109
  """Best-effort check to keep backend-only Codex tasks away from frontend files."""
69
110
  normalized = _normalize_path_text(path_value).lower().lstrip("./")
@@ -255,6 +296,10 @@ def _execute_codex_session(
255
296
  if session_id:
256
297
  cmd.extend(["resume", str(session_id)])
257
298
 
299
+ policy_context = _load_project_codex_policy_context(cd)
300
+ if policy_context:
301
+ prompt = f"{policy_context}\n\nUser task:\n{prompt}"
302
+
258
303
  if os.name == "nt":
259
304
  prompt = windows_escape(prompt)
260
305
  cmd += ["--", prompt]
@@ -18,6 +18,10 @@ from mcp.server.fastmcp import FastMCP
18
18
  from pydantic import BeforeValidator, Field
19
19
  import shutil
20
20
 
21
+ DEFAULT_GEMINI_APPROVAL_MODE = "yolo"
22
+ DEFAULT_GEMINI_TIMEOUT_SECONDS = 600
23
+ PROJECT_GEMINI_POLICY_RELATIVE_PATH = Path(".gemini") / "policies" / "codecgc-policy.toml"
24
+
21
25
  mcp = FastMCP("Gemini MCP Server-from guda.studio")
22
26
 
23
27
  # Mirror of model-routing.yaml backend_paths — keep these hints in sync with
@@ -49,6 +53,14 @@ def _normalize_path_text(path_value: Path | str) -> str:
49
53
  return str(path_value).replace("\\", "/").strip()
50
54
 
51
55
 
56
+ def _resolve_project_gemini_policy(cd: Path) -> Path | None:
57
+ """Return the CodeCGC project-local Gemini policy if the workspace installed it."""
58
+ policy_path = cd / PROJECT_GEMINI_POLICY_RELATIVE_PATH
59
+ if policy_path.is_file():
60
+ return policy_path
61
+ return None
62
+
63
+
52
64
  def _is_probably_backend_path(path_value: Path | str) -> bool:
53
65
  """Best-effort check to keep frontend-only Gemini tasks away from backend files."""
54
66
  normalized = _normalize_path_text(path_value).lower().lstrip("./")
@@ -126,7 +138,30 @@ def _validate_frontend_target_paths(target_paths: List[Path]) -> tuple[bool, Lis
126
138
  return True, policy_checks, ""
127
139
 
128
140
 
129
- def run_shell_command(cmd: list[str], cwd: str | None = None) -> Generator[str, None, None]:
141
+ def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
142
+ """Terminate a process and its children best-effort."""
143
+ if process.poll() is not None:
144
+ return
145
+
146
+ if os.name == "nt":
147
+ subprocess.run(
148
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
149
+ stdin=subprocess.DEVNULL,
150
+ stdout=subprocess.DEVNULL,
151
+ stderr=subprocess.DEVNULL,
152
+ check=False,
153
+ )
154
+ return
155
+
156
+ process.kill()
157
+
158
+
159
+ def run_shell_command(
160
+ cmd: list[str],
161
+ cwd: str | None = None,
162
+ timeout_seconds: int = DEFAULT_GEMINI_TIMEOUT_SECONDS,
163
+ env: dict[str, str] | None = None,
164
+ ) -> Generator[str, None, None]:
130
165
  """Execute a command and stream its output line-by-line.
131
166
 
132
167
  Args:
@@ -154,10 +189,13 @@ def run_shell_command(cmd: list[str], cwd: str | None = None) -> Generator[str,
154
189
  universal_newlines=True,
155
190
  encoding='utf-8',
156
191
  cwd=cwd,
192
+ env=env,
157
193
  )
158
194
 
159
195
  output_queue: queue.Queue[str | None] = queue.Queue()
160
196
  GRACEFUL_SHUTDOWN_DELAY = 0.3
197
+ started_at = time.monotonic()
198
+ timed_out = False
161
199
 
162
200
  def is_turn_completed(line: str) -> bool:
163
201
  """Check if the line indicates turn completion via JSON parsing."""
@@ -185,6 +223,11 @@ def run_shell_command(cmd: list[str], cwd: str | None = None) -> Generator[str,
185
223
 
186
224
  # Yield lines while process is running
187
225
  while True:
226
+ if timeout_seconds > 0 and time.monotonic() - started_at > timeout_seconds:
227
+ timed_out = True
228
+ _terminate_process_tree(process)
229
+ break
230
+
188
231
  try:
189
232
  line = output_queue.get(timeout=0.5)
190
233
  if line is None:
@@ -197,7 +240,7 @@ def run_shell_command(cmd: list[str], cwd: str | None = None) -> Generator[str,
197
240
  try:
198
241
  process.wait(timeout=5)
199
242
  except subprocess.TimeoutExpired:
200
- process.kill()
243
+ _terminate_process_tree(process)
201
244
  process.wait()
202
245
  thread.join(timeout=5)
203
246
 
@@ -209,6 +252,13 @@ def run_shell_command(cmd: list[str], cwd: str | None = None) -> Generator[str,
209
252
  except queue.Empty:
210
253
  break
211
254
 
255
+ if timed_out:
256
+ raise TimeoutError(
257
+ f"Gemini CLI timed out after {timeout_seconds} seconds. "
258
+ "This usually means the CLI was waiting for interactive approval, "
259
+ "network/authentication, or a long-running tool call."
260
+ )
261
+
212
262
 
213
263
  def _execute_gemini_session(
214
264
  *,
@@ -218,6 +268,7 @@ def _execute_gemini_session(
218
268
  session_id: str,
219
269
  return_all_messages: bool,
220
270
  model: str,
271
+ timeout_seconds: int = DEFAULT_GEMINI_TIMEOUT_SECONDS,
221
272
  ) -> Dict[str, Any]:
222
273
  """Execute Gemini CLI and return the parsed MCP response payload."""
223
274
  if not cd.exists():
@@ -229,7 +280,19 @@ def _execute_gemini_session(
229
280
  if os.name == "nt":
230
281
  prompt = windows_escape(prompt)
231
282
 
232
- cmd = ["gemini", "--skip-trust", "--prompt", prompt, "-o", "stream-json"]
283
+ effective_timeout_seconds = int(timeout_seconds or 0) or DEFAULT_GEMINI_TIMEOUT_SECONDS
284
+ cmd = [
285
+ "gemini",
286
+ "--skip-trust",
287
+ "--approval-mode",
288
+ DEFAULT_GEMINI_APPROVAL_MODE,
289
+ "-o",
290
+ "stream-json",
291
+ ]
292
+
293
+ project_policy = _resolve_project_gemini_policy(cd)
294
+ if project_policy is not None:
295
+ cmd.extend(["--policy", project_policy.absolute().as_posix()])
233
296
 
234
297
  if sandbox:
235
298
  cmd.extend(["--sandbox"])
@@ -240,33 +303,42 @@ def _execute_gemini_session(
240
303
  if session_id:
241
304
  cmd.extend(["--resume", session_id])
242
305
 
306
+ cmd.append(prompt)
307
+
308
+ gemini_env = {**os.environ, "GEMINI_CLI_TRUST_WORKSPACE": "true"}
309
+
243
310
  all_messages = []
244
311
  agent_messages = ""
245
312
  success = True
246
313
  err_message = ""
247
314
  thread_id: Optional[str] = None
248
315
 
249
- for line in run_shell_command(cmd, cwd=cd.absolute().as_posix()):
250
- try:
251
- line_dict = json.loads(line.strip())
252
- all_messages.append(line_dict)
253
- item_type = line_dict.get("type", "")
254
- item_role = line_dict.get("role", "")
255
- if item_type == "message" and item_role == "assistant":
256
- if (
257
- "The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n"
258
- in line_dict.get("content", "")
259
- ):
260
- continue
261
- agent_messages = agent_messages + line_dict.get("content", "")
262
- if line_dict.get("session_id") is not None:
263
- thread_id = line_dict.get("session_id")
264
- except json.JSONDecodeError:
265
- err_message += "\n\n[json decode error] " + line
266
- continue
267
- except Exception as error:
268
- err_message += "\n\n[unexpected error] " + f"Unexpected error: {error}. Line: {line!r}"
269
- break
316
+ try:
317
+ for line in run_shell_command(
318
+ cmd,
319
+ cwd=cd.absolute().as_posix(),
320
+ timeout_seconds=effective_timeout_seconds,
321
+ env=gemini_env,
322
+ ):
323
+ try:
324
+ line_dict = json.loads(line.strip())
325
+ all_messages.append(line_dict)
326
+ item_type = line_dict.get("type", "")
327
+ item_role = line_dict.get("role", "")
328
+ if item_type == "message" and item_role == "assistant":
329
+ agent_messages = agent_messages + line_dict.get("content", "")
330
+ if line_dict.get("session_id") is not None:
331
+ thread_id = line_dict.get("session_id")
332
+ except json.JSONDecodeError:
333
+ err_message += "\n\n[json decode error] " + line
334
+ continue
335
+ except Exception as error:
336
+ err_message += "\n\n[unexpected error] " + f"Unexpected error: {error}. Line: {line!r}"
337
+ success = False
338
+ break
339
+ except TimeoutError as error:
340
+ success = False
341
+ err_message += "\n\n[timeout] " + str(error)
270
342
 
271
343
  if thread_id is None:
272
344
  success = False
@@ -359,6 +431,10 @@ async def gemini(
359
431
  str,
360
432
  "The model to use for the gemini session. This parameter is strictly prohibited unless explicitly specified by the user.",
361
433
  ] = "",
434
+ timeout_seconds: Annotated[
435
+ int,
436
+ Field(description="Maximum Gemini CLI process runtime in seconds. Defaults to 600."),
437
+ ] = DEFAULT_GEMINI_TIMEOUT_SECONDS,
362
438
  ) -> Dict[str, Any]:
363
439
  """Execute a gemini CLI session and return the results."""
364
440
  return await asyncio.to_thread(
@@ -370,6 +446,7 @@ async def gemini(
370
446
  session_id=SESSION_ID,
371
447
  return_all_messages=return_all_messages,
372
448
  model=model,
449
+ timeout_seconds=timeout_seconds,
373
450
  )
374
451
  )
375
452
 
@@ -421,6 +498,10 @@ async def implement_frontend_task(
421
498
  str,
422
499
  "Optional model override. Only use when explicitly requested by the user.",
423
500
  ] = "",
501
+ timeout_seconds: Annotated[
502
+ int,
503
+ Field(description="Maximum Gemini CLI process runtime in seconds. Defaults to 600."),
504
+ ] = DEFAULT_GEMINI_TIMEOUT_SECONDS,
424
505
  ) -> Dict[str, Any]:
425
506
  """Execute a frontend-only Gemini task with CodeCGC policy checks."""
426
507
  valid, policy_checks, validation_error = _validate_frontend_target_paths(target_paths)
@@ -447,6 +528,7 @@ async def implement_frontend_task(
447
528
  session_id=SESSION_ID,
448
529
  return_all_messages=return_all_messages,
449
530
  model=model,
531
+ timeout_seconds=timeout_seconds,
450
532
  )
451
533
  )
452
534
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunyed15/codecgc",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Claude-hosted multi-model workflow product shell for CodeCGC.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -64,6 +64,7 @@
64
64
  "codecgc/compound/",
65
65
  "codecgc/reference/",
66
66
  "codecgc/roadmap/",
67
+ "codecgc/templates/",
67
68
  "codecgcmcp/pyproject.toml",
68
69
  "codecgcmcp/README.md",
69
70
  "codecgcmcp/src/codecgcmcp/*.py",
@@ -22,6 +22,9 @@ RUNTIME_ENTRYPOINTS = [
22
22
 
23
23
  RUNTIME_STATIC_REQUIREMENTS = [
24
24
  ".claude/hooks/route-edit.ps1",
25
+ "codecgc/templates/claude/settings.local.json",
26
+ "codecgc/templates/codex/codecgcrc.json",
27
+ "codecgc/templates/gemini/codecgc-policy.toml",
25
28
  "model-routing.yaml",
26
29
  "requirements.txt",
27
30
  "scripts/codecgc_runtime/__init__.py",
@@ -209,6 +209,7 @@ def load_explicit_task_payload(args: argparse.Namespace) -> dict[str, Any]:
209
209
  "codex_sandbox": args.codex_sandbox or "workspace-write",
210
210
  "gemini_sandbox": bool(args.gemini_sandbox),
211
211
  "return_all_messages": bool(args.return_all_messages),
212
+ "timeout_seconds": int(getattr(args, "timeout_seconds", 0) or 0),
212
213
  "source": None,
213
214
  }
214
215
 
@@ -345,6 +346,10 @@ def build_tool_call(args: argparse.Namespace, routing: dict[str, Any]) -> dict[s
345
346
  + json.dumps(policy_result, ensure_ascii=False)
346
347
  )
347
348
 
349
+ effective_timeout_seconds = int(payload_inputs.get("timeout_seconds", 0)) or int(
350
+ getattr(args, "timeout_seconds", 0) or 0
351
+ )
352
+
348
353
  if kind == "frontend":
349
354
  tool_name = "implement_frontend_task"
350
355
  tool_args: dict[str, Any] = {
@@ -358,6 +363,7 @@ def build_tool_call(args: argparse.Namespace, routing: dict[str, Any]) -> dict[s
358
363
  "sandbox": payload_inputs["gemini_sandbox"],
359
364
  "return_all_messages": payload_inputs["return_all_messages"],
360
365
  "model": payload_inputs["model"],
366
+ "timeout_seconds": effective_timeout_seconds,
361
367
  }
362
368
  elif kind == "backend":
363
369
  tool_name = "implement_backend_task"
@@ -382,6 +388,7 @@ def build_tool_call(args: argparse.Namespace, routing: dict[str, Any]) -> dict[s
382
388
  "target": kind,
383
389
  "tool_name": tool_name,
384
390
  "tool_args": tool_args,
391
+ "timeout_seconds": effective_timeout_seconds,
385
392
  "route_notes": route_notes,
386
393
  "policy": policy_result,
387
394
  "routing_file": payload_inputs["routing_file"],
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import re
6
+ import shlex
5
7
  import sys
6
8
  from dataclasses import dataclass
7
9
  from fnmatch import fnmatch
@@ -29,6 +31,71 @@ class PathDecision:
29
31
  recommended_action: str
30
32
 
31
33
 
34
+ @dataclass(frozen=True)
35
+ class HookRequest:
36
+ tool_name: str
37
+ file_path: str
38
+ command: str
39
+
40
+
41
+ EDIT_TOOL_NAMES = {"Edit", "Write", "MultiEdit"}
42
+ SHELL_TOOL_NAMES = {"Bash", "PowerShell"}
43
+ SHELL_DENIED_COMMAND_PATTERNS = (
44
+ r"\brm\s+-[^\n\r]*[rf]",
45
+ r"\bdel\s+",
46
+ r"\brmdir\s+",
47
+ r"\bremove-item\b",
48
+ r"\bset-content\b",
49
+ r"\badd-content\b",
50
+ r"\bout-file\b",
51
+ r"\bnew-item\b",
52
+ r"\bcopy-item\b",
53
+ r"\bmove-item\b",
54
+ r"\bgit\s+reset\s+--hard\b",
55
+ r"\bgit\s+clean\b",
56
+ r"\bpython\s+-c\b",
57
+ r"\bnode\s+-e\b",
58
+ r"\bpowershell(\.exe)?\s+-(command|encodedcommand)\b",
59
+ r"\bcmd(\.exe)?\s+/c\b",
60
+ )
61
+ SHELL_ALLOWED_COMMAND_PREFIXES = (
62
+ "git status",
63
+ "git diff",
64
+ "git log",
65
+ "git show",
66
+ "git branch",
67
+ "git rev-parse",
68
+ "git ls-files",
69
+ "git remote",
70
+ "rg",
71
+ "ls",
72
+ "dir",
73
+ "pwd",
74
+ "get-content",
75
+ "get-childitem",
76
+ "select-string",
77
+ "test-path",
78
+ "resolve-path",
79
+ "pytest",
80
+ "python -m pytest",
81
+ "python -m compileall",
82
+ "npm test",
83
+ "npm run test",
84
+ "npm run lint",
85
+ "npm run typecheck",
86
+ "npm run check",
87
+ "pnpm test",
88
+ "pnpm run test",
89
+ "pnpm run lint",
90
+ "pnpm run typecheck",
91
+ "yarn test",
92
+ "yarn lint",
93
+ "codex --help",
94
+ "gemini --help",
95
+ "gemini --version",
96
+ )
97
+
98
+
32
99
  def normalize_path_text(path_text: str) -> str:
33
100
  normalized = str(path_text or "").replace("\\", "/").strip()
34
101
  while normalized.startswith("./"):
@@ -207,18 +274,111 @@ def validate_executor_target(kind: str, target_paths: list[str], policy: dict[st
207
274
  return result
208
275
 
209
276
 
210
- def parse_hook_payload(text: str) -> tuple[str, str]:
277
+ def parse_hook_request(text: str) -> HookRequest:
211
278
  if not text.strip():
212
- return "", ""
279
+ return HookRequest(tool_name="", file_path="", command="")
213
280
  payload = json.loads(text)
214
281
  if not isinstance(payload, dict):
215
- return "", ""
282
+ return HookRequest(tool_name="", file_path="", command="")
216
283
  tool_name = str(payload.get("tool_name", "")).strip()
217
284
  tool_input = payload.get("tool_input", {})
218
285
  if not isinstance(tool_input, dict):
219
- return tool_name, ""
286
+ return HookRequest(tool_name=tool_name, file_path="", command="")
220
287
  file_path = str(tool_input.get("file_path") or tool_input.get("path") or "").strip()
221
- return tool_name, file_path
288
+ command = str(tool_input.get("command") or tool_input.get("script") or "").strip()
289
+ return HookRequest(tool_name=tool_name, file_path=file_path, command=command)
290
+
291
+
292
+ def parse_hook_payload(text: str) -> tuple[str, str]:
293
+ request = parse_hook_request(text)
294
+ return request.tool_name, request.file_path
295
+
296
+
297
+ def has_unquoted_shell_control_operator(command: str) -> bool:
298
+ in_single = False
299
+ in_double = False
300
+ escaped = False
301
+ for index, char in enumerate(command):
302
+ if escaped:
303
+ escaped = False
304
+ continue
305
+ if char == "\\":
306
+ escaped = True
307
+ continue
308
+ if char == "'" and not in_double:
309
+ in_single = not in_single
310
+ continue
311
+ if char == '"' and not in_single:
312
+ in_double = not in_double
313
+ continue
314
+ if in_single or in_double:
315
+ continue
316
+ if char in {"|", ";", ">", "<", "`", "&"}:
317
+ return True
318
+ if char == "$" and index + 1 < len(command) and command[index + 1] == "(":
319
+ return True
320
+ return False
321
+
322
+
323
+ def normalize_shell_command_for_prefix(command: str) -> str:
324
+ try:
325
+ parts = shlex.split(command, posix=False)
326
+ except ValueError:
327
+ parts = command.split()
328
+ if not parts:
329
+ return ""
330
+
331
+ first = parts[0].strip().strip("'\"")
332
+ first_name = Path(first.replace("\\", "/")).name.lower()
333
+ for suffix in (".cmd", ".ps1", ".exe", ".bat"):
334
+ if first_name.endswith(suffix):
335
+ first_name = first_name[: -len(suffix)]
336
+ break
337
+ rest = " ".join(str(item).strip().strip("'\"").lower() for item in parts[1:])
338
+ return f"{first_name} {rest}".strip()
339
+
340
+
341
+ def shell_prefix_matches(command: str, prefix: str) -> bool:
342
+ return command == prefix or command.startswith(f"{prefix} ")
343
+
344
+
345
+ def is_codecgc_cli_shell_command(normalized_command: str) -> bool:
346
+ first = normalized_command.split(" ", 1)[0]
347
+ return first == "cgc" or first == "codecgc" or first.startswith("cgc-")
348
+
349
+
350
+ def evaluate_shell_command(command: str, tool_name: str = "Bash") -> dict[str, Any]:
351
+ normalized_tool_name = str(tool_name or "").strip()
352
+ if not str(command or "").strip():
353
+ return {"allowed": True, "reason": "", "recommended_action": ""}
354
+
355
+ if has_unquoted_shell_control_operator(command):
356
+ return {
357
+ "allowed": False,
358
+ "reason": f"{normalized_tool_name} commands with shell control operators are not allowed for Claude",
359
+ "recommended_action": "route write work through /cgc or run a single read-only check",
360
+ }
361
+
362
+ lowered = command.lower()
363
+ if any(re.search(pattern, lowered) for pattern in SHELL_DENIED_COMMAND_PATTERNS):
364
+ return {
365
+ "allowed": False,
366
+ "reason": f"{normalized_tool_name} command looks like a direct write or destructive shell operation",
367
+ "recommended_action": "route implementation through /cgc so CodeCGC can dispatch Codex or Gemini",
368
+ }
369
+
370
+ normalized_command = normalize_shell_command_for_prefix(command)
371
+ if is_codecgc_cli_shell_command(normalized_command):
372
+ return {"allowed": True, "reason": "", "recommended_action": ""}
373
+
374
+ if any(shell_prefix_matches(normalized_command, prefix) for prefix in SHELL_ALLOWED_COMMAND_PREFIXES):
375
+ return {"allowed": True, "reason": "", "recommended_action": ""}
376
+
377
+ return {
378
+ "allowed": False,
379
+ "reason": f"{normalized_tool_name} command is outside the CodeCGC Claude shell allowlist",
380
+ "recommended_action": "use /cgc for write work, or use an allowed read-only/test command",
381
+ }
222
382
 
223
383
 
224
384
  def build_hook_response(allowed: bool, reason: str) -> dict[str, Any]:
@@ -247,11 +407,23 @@ def main() -> int:
247
407
  policy = load_policy(policy_path)
248
408
 
249
409
  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:
410
+ request = parse_hook_request(sys.stdin.read())
411
+ if request.tool_name in SHELL_TOOL_NAMES:
412
+ result = evaluate_shell_command(request.command, request.tool_name)
413
+ if result["allowed"]:
414
+ print_json(build_hook_response(True, ""))
415
+ return 0
416
+ hook_reason = f"CodeCGC: {result['reason']}."
417
+ recommended = str(result.get("recommended_action", "")).strip()
418
+ if recommended:
419
+ hook_reason += f" {recommended}."
420
+ print_json(build_hook_response(False, hook_reason))
421
+ return 0
422
+
423
+ if request.tool_name not in EDIT_TOOL_NAMES or not request.file_path:
252
424
  print_json(build_hook_response(True, ""))
253
425
  return 0
254
- result = evaluate_paths([file_path], actor=args.actor, operation=args.operation, policy=policy)
426
+ result = evaluate_paths([request.file_path], actor=args.actor, operation=args.operation, policy=policy)
255
427
  decision = result["decisions"][0]
256
428
  reason = decision.get("reason", "")
257
429
  recommended = decision.get("recommended_action", "")
@@ -34,7 +34,9 @@ DEFAULT_SHARED_PATHS = [
34
34
  DEFAULT_ORCHESTRATION_PATHS = [
35
35
  "codecgc/**",
36
36
  ".claude/commands/**",
37
- ".claude/settings.json",
37
+ ".claude/settings.local.json",
38
+ ".codex/codecgcrc.json",
39
+ ".gemini/policies/**",
38
40
  ".mcp.json",
39
41
  "model-routing.yaml",
40
42
  ]
@@ -25,10 +25,14 @@ SETTINGS_PATH = CLAUDE_DIR / "settings.json"
25
25
  MCP_CONFIG_PATH = WORKSPACE / ".mcp.json"
26
26
  PROJECT_HOOK_PATH = HOOKS_DIR / "route-edit.ps1"
27
27
  PROJECT_ROUTING_PATH = WORKSPACE / "model-routing.yaml"
28
- EDIT_GUARDRAIL_MATCHER = "Edit|Write|MultiEdit"
29
- LEGACY_EDIT_GUARDRAIL_MATCHERS = {"Edit|Write"}
28
+ PROJECT_TEMPLATES_DIR = WORKSPACE / "codecgc" / "templates"
29
+ EDIT_GUARDRAIL_MATCHER = "Edit|Write|MultiEdit|Bash|PowerShell"
30
+ LEGACY_EDIT_GUARDRAIL_MATCHERS = {"Edit|Write", "Edit|Write|MultiEdit"}
30
31
  PROJECT_ONBOARDING_RELATIVE_PATH = "codecgc/START_HERE.md"
31
32
  PROJECT_ONBOARDING_MARKER = "<!-- codecgc:onboarding:v1 -->"
33
+ CLAUDE_SETTINGS_TEMPLATE = PROJECT_TEMPLATES_DIR / "claude" / "settings.local.json"
34
+ CODEX_POLICY_TEMPLATE = PROJECT_TEMPLATES_DIR / "codex" / "codecgcrc.json"
35
+ GEMINI_POLICY_TEMPLATE = PROJECT_TEMPLATES_DIR / "gemini" / "codecgc-policy.toml"
32
36
 
33
37
 
34
38
  DEFAULT_HOOKS = {
@@ -98,16 +102,24 @@ def get_workspace_paths(override_workspace: str = "") -> dict[str, Path]:
98
102
  root = resolve_workspace_root(override_workspace)
99
103
  claude_dir = root / ".claude"
100
104
  hooks_dir = claude_dir / "hooks"
105
+ codex_dir = root / ".codex"
106
+ gemini_dir = root / ".gemini"
101
107
  return {
102
108
  "root": root,
103
109
  "claude_dir": claude_dir,
104
110
  "hooks_dir": hooks_dir,
105
- "settings": claude_dir / "settings.json",
111
+ "settings": claude_dir / "settings.local.json",
112
+ "legacy_settings": claude_dir / "settings.json",
106
113
  "mcp": root / ".mcp.json",
107
114
  "hook_script": hooks_dir / "route-edit.ps1",
108
115
  "commands_dir": claude_dir / "commands",
109
116
  "routing_file": root / "model-routing.yaml",
110
117
  "onboarding_file": root / PROJECT_ONBOARDING_RELATIVE_PATH,
118
+ "codex_dir": codex_dir,
119
+ "codex_policy": codex_dir / "codecgcrc.json",
120
+ "gemini_dir": gemini_dir,
121
+ "gemini_policies_dir": gemini_dir / "policies",
122
+ "gemini_policy": gemini_dir / "policies" / "codecgc-policy.toml",
111
123
  }
112
124
 
113
125
 
@@ -123,6 +135,12 @@ def load_text_file(path: Path) -> str:
123
135
  return path.read_text(encoding="utf-8")
124
136
 
125
137
 
138
+ def copy_template_file(template_path: Path, target_path: Path) -> Path:
139
+ target_path.parent.mkdir(parents=True, exist_ok=True)
140
+ shutil.copyfile(template_path, target_path)
141
+ return target_path
142
+
143
+
126
144
  def ensure_workspace_workflow_dirs(workspace_root: Path) -> list[str]:
127
145
  created_or_existing: list[str] = []
128
146
  for relative_path in PROJECT_WORKFLOW_DIRS:
@@ -174,9 +192,11 @@ cgc "新增一个登录页面,放在 src/components/LoginForm.tsx"
174
192
  ```text
175
193
  .mcp.json
176
194
  model-routing.yaml
177
- .claude/settings.json
195
+ .claude/settings.local.json
178
196
  .claude/hooks/route-edit.ps1
179
197
  .claude/commands/cgc*.md
198
+ .codex/codecgcrc.json
199
+ .gemini/policies/codecgc-policy.toml
180
200
  codecgc/features/
181
201
  codecgc/issues/
182
202
  codecgc/execution/
@@ -657,6 +677,16 @@ def write_json_file(path: Path, payload: dict[str, Any]) -> Path:
657
677
  return path
658
678
 
659
679
 
680
+ def build_project_claude_settings(workspace_paths: dict[str, Path]) -> dict[str, Any]:
681
+ settings = load_json_file(CLAUDE_SETTINGS_TEMPLATE)
682
+ merged_settings, _ = merge_hook_settings(
683
+ settings,
684
+ build_workspace_hook_command(workspace_paths),
685
+ )
686
+ merged_settings, _ = merge_permission_settings(merged_settings, DEFAULT_ALLOWED_TOOLS)
687
+ return merged_settings
688
+
689
+
660
690
  def shell_quote(value: str) -> str:
661
691
  text = str(value)
662
692
  if not text:
@@ -851,16 +881,12 @@ def install_local_runtime(override_workspace: str = "") -> dict[str, Any]:
851
881
  workspace_paths["hooks_dir"].mkdir(parents=True, exist_ok=True)
852
882
  workflow_dirs = ensure_workspace_workflow_dirs(workspace_paths["root"])
853
883
 
854
- settings = load_json_file(workspace_paths["settings"])
855
- merged_settings, settings_changed = merge_hook_settings(
856
- settings,
857
- build_workspace_hook_command(workspace_paths),
884
+ write_json_file(
885
+ workspace_paths["settings"],
886
+ build_project_claude_settings(workspace_paths),
858
887
  )
859
- merged_settings, permissions_changed = merge_permission_settings(merged_settings, DEFAULT_ALLOWED_TOOLS)
860
- if settings_changed or not workspace_paths["settings"].exists():
861
- write_json_file(workspace_paths["settings"], merged_settings)
862
- elif permissions_changed:
863
- write_json_file(workspace_paths["settings"], merged_settings)
888
+ codex_policy_path = copy_template_file(CODEX_POLICY_TEMPLATE, workspace_paths["codex_policy"])
889
+ gemini_policy_path = copy_template_file(GEMINI_POLICY_TEMPLATE, workspace_paths["gemini_policy"])
864
890
 
865
891
  if PROJECT_HOOK_PATH.resolve() != workspace_paths["hook_script"].resolve():
866
892
  shutil.copyfile(PROJECT_HOOK_PATH, workspace_paths["hook_script"])
@@ -880,6 +906,8 @@ def install_local_runtime(override_workspace: str = "") -> dict[str, Any]:
880
906
  "mcp_config": str(mcp_path),
881
907
  "routing_file": str(routing_path),
882
908
  "claude_settings": str(workspace_paths["settings"]),
909
+ "codex_policy": str(codex_policy_path),
910
+ "gemini_policy": str(gemini_policy_path),
883
911
  "hook_script": str(workspace_paths["hook_script"]),
884
912
  "commands_dir": str(workspace_paths["commands_dir"]),
885
913
  "onboarding_file": str(onboarding_file),
@@ -890,7 +918,9 @@ def install_local_runtime(override_workspace: str = "") -> dict[str, Any]:
890
918
  "Project-local model-routing.yaml was synchronized as the policy source of truth.",
891
919
  "Project-local codecgc workflow directories were initialized.",
892
920
  "Claude pre-edit guardrail hook was synchronized into the target workspace.",
893
- "Claude MCP tool permissions were merged into project .claude/settings.json.",
921
+ "Claude project permissions were rendered from codecgc/templates/claude/settings.local.json.",
922
+ "Project-local Codex policy contract was synchronized into .codex/codecgcrc.json.",
923
+ "Project-local Gemini policy was synchronized into .gemini/policies/codecgc-policy.toml.",
894
924
  "Project-local Claude slash commands were synchronized into .claude/commands.",
895
925
  "Project-local codecgc/START_HERE.md was written as the first-run entry guide.",
896
926
  "This mode prepares project-level integration surfaces for the selected workspace.",
@@ -1026,6 +1056,8 @@ def build_install_mode_summary(result: dict[str, Any]) -> str:
1026
1056
  f"- MCP 配置: {result.get('mcp_config', '')}",
1027
1057
  f"- Routing 文件: {result.get('routing_file', '')}",
1028
1058
  f"- Claude 设置: {result.get('claude_settings', '')}",
1059
+ f"- Codex 策略: {result.get('codex_policy', '')}",
1060
+ f"- Gemini 策略: {result.get('gemini_policy', '')}",
1029
1061
  f"- Hook 脚本: {result.get('hook_script', '')}",
1030
1062
  f"- Slash Commands: {result.get('commands_dir', '')}",
1031
1063
  f"- 新手入口: {result.get('onboarding_file', '')}",
@@ -1084,9 +1116,14 @@ def collect_project_status(workspace_paths: dict[str, Path]) -> dict[str, Any]:
1084
1116
  expected_mcp = build_mcp_config(workspace_paths["root"])
1085
1117
  expected_hook_command = build_workspace_hook_command(workspace_paths)
1086
1118
  expected_hook_text = load_text_file(PROJECT_HOOK_PATH)
1119
+ expected_settings = build_project_claude_settings(workspace_paths)
1120
+ expected_codex_policy = load_text_file(CODEX_POLICY_TEMPLATE)
1121
+ expected_gemini_policy = load_text_file(GEMINI_POLICY_TEMPLATE)
1087
1122
  current_settings = load_json_file(workspace_paths["settings"])
1088
1123
  current_mcp = load_json_file(workspace_paths["mcp"])
1089
1124
  current_hook_text = load_text_file(workspace_paths["hook_script"])
1125
+ current_codex_policy = load_text_file(workspace_paths["codex_policy"])
1126
+ current_gemini_policy = load_text_file(workspace_paths["gemini_policy"])
1090
1127
  routing_exists = workspace_paths["routing_file"].exists()
1091
1128
  policy_valid = policy_file_is_valid(workspace_paths["routing_file"]) if routing_exists else False
1092
1129
  workflow_dirs_ready = workspace_workflow_dirs_ready(workspace_paths["root"])
@@ -1094,8 +1131,15 @@ def collect_project_status(workspace_paths: dict[str, Path]) -> dict[str, Any]:
1094
1131
 
1095
1132
  hook_registered = settings_have_hook_command(current_settings, expected_hook_command)
1096
1133
  permissions_registered = settings_have_allowed_tools(current_settings, DEFAULT_ALLOWED_TOOLS)
1134
+ settings_matches = current_settings == expected_settings if workspace_paths["settings"].exists() else False
1097
1135
  mcp_matches = current_mcp == expected_mcp if workspace_paths["mcp"].exists() else False
1098
1136
  hook_file_matches = current_hook_text == expected_hook_text if workspace_paths["hook_script"].exists() else False
1137
+ codex_policy_matches = (
1138
+ current_codex_policy == expected_codex_policy if workspace_paths["codex_policy"].exists() else False
1139
+ )
1140
+ gemini_policy_matches = (
1141
+ current_gemini_policy == expected_gemini_policy if workspace_paths["gemini_policy"].exists() else False
1142
+ )
1099
1143
 
1100
1144
  missing = []
1101
1145
  if not routing_exists:
@@ -1106,6 +1150,8 @@ def collect_project_status(workspace_paths: dict[str, Path]) -> dict[str, Any]:
1106
1150
  missing.append("workflow_dirs")
1107
1151
  if not mcp_matches:
1108
1152
  missing.append("mcp_json")
1153
+ if not settings_matches:
1154
+ missing.append("claude_settings_local")
1109
1155
  if not hook_registered:
1110
1156
  missing.append("claude_settings_hook")
1111
1157
  if not permissions_registered:
@@ -1114,12 +1160,19 @@ def collect_project_status(workspace_paths: dict[str, Path]) -> dict[str, Any]:
1114
1160
  missing.append("hook_script")
1115
1161
  if not onboarding_ready:
1116
1162
  missing.append("onboarding_file")
1163
+ if not codex_policy_matches:
1164
+ missing.append("codex_policy")
1165
+ if not gemini_policy_matches:
1166
+ missing.append("gemini_policy")
1117
1167
 
1118
1168
  ready = not missing
1119
1169
  return {
1120
1170
  "mcp_json_path": str(workspace_paths["mcp"]),
1121
1171
  "routing_file_path": str(workspace_paths["routing_file"]),
1122
1172
  "claude_settings_path": str(workspace_paths["settings"]),
1173
+ "legacy_claude_settings_path": str(workspace_paths["legacy_settings"]),
1174
+ "codex_policy_path": str(workspace_paths["codex_policy"]),
1175
+ "gemini_policy_path": str(workspace_paths["gemini_policy"]),
1123
1176
  "hook_script_path": str(workspace_paths["hook_script"]),
1124
1177
  "onboarding_file_path": str(workspace_paths["onboarding_file"]),
1125
1178
  "mcp_json_exists": workspace_paths["mcp"].exists(),
@@ -1129,12 +1182,18 @@ def collect_project_status(workspace_paths: dict[str, Path]) -> dict[str, Any]:
1129
1182
  "onboarding_ready": onboarding_ready,
1130
1183
  "workflow_dirs_expected": [str(workspace_paths["root"] / item) for item in PROJECT_WORKFLOW_DIRS],
1131
1184
  "claude_settings_exists": workspace_paths["settings"].exists(),
1185
+ "legacy_claude_settings_exists": workspace_paths["legacy_settings"].exists(),
1186
+ "codex_policy_exists": workspace_paths["codex_policy"].exists(),
1187
+ "gemini_policy_exists": workspace_paths["gemini_policy"].exists(),
1132
1188
  "hook_exists": workspace_paths["hook_script"].exists(),
1133
1189
  "onboarding_exists": workspace_paths["onboarding_file"].exists(),
1134
1190
  "mcp_matches_expected": mcp_matches,
1191
+ "claude_settings_matches_expected": settings_matches,
1135
1192
  "hook_registered": hook_registered,
1136
1193
  "permissions_registered": permissions_registered,
1137
1194
  "hook_file_matches_expected": hook_file_matches,
1195
+ "codex_policy_matches_expected": codex_policy_matches,
1196
+ "gemini_policy_matches_expected": gemini_policy_matches,
1138
1197
  "ready": ready,
1139
1198
  "missing_or_outdated": missing,
1140
1199
  "recommended_command": "" if ready else build_workspace_install_command(workspace_paths["root"]),