codevoight 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import time
6
+ from pathlib import Path
7
+ from contextlib import contextmanager
8
+ from typing import Iterator, List, Optional
9
+
10
+ from codevoight.core.models import (
11
+ QuizOption,
12
+ QuizQuestion,
13
+ SessionStatus,
14
+ StagedSession,
15
+ )
16
+ from codevoight.db.schema import init_db
17
+
18
+
19
+ class StateRepository:
20
+ def __init__(self, db_path: Path):
21
+ self.db_path = db_path
22
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
23
+ self._ensure_init()
24
+
25
+ @contextmanager
26
+ def _get_connection(self) -> Iterator[sqlite3.Connection]:
27
+ conn = sqlite3.connect(str(self.db_path), timeout=10.0)
28
+ conn.row_factory = sqlite3.Row
29
+ try:
30
+ with conn:
31
+ yield conn
32
+ finally:
33
+ conn.close()
34
+
35
+ def _ensure_init(self) -> None:
36
+ with self._get_connection() as conn:
37
+ init_db(conn)
38
+
39
+ def create_session(self, session: StagedSession, questions: List[QuizQuestion]) -> None:
40
+ with self._get_connection() as conn:
41
+ cursor = conn.cursor()
42
+ cursor.execute(
43
+ """
44
+ INSERT INTO sessions (
45
+ session_id, diff_text, diff_hash, description, status,
46
+ created_at, expires_at, capability_token
47
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
48
+ """,
49
+ (
50
+ session.session_id,
51
+ session.diff_text,
52
+ session.diff_hash,
53
+ session.description,
54
+ session.status.value,
55
+ session.created_at,
56
+ session.expires_at,
57
+ session.capability_token,
58
+ ),
59
+ )
60
+
61
+ for q in questions:
62
+ options_data = [opt.model_dump() for opt in q.options]
63
+ cursor.execute(
64
+ """
65
+ INSERT INTO questions (
66
+ id, session_id, prompt, options_json, correct_option_id, explanation
67
+ ) VALUES (?, ?, ?, ?, ?, ?)
68
+ """,
69
+ (
70
+ q.id,
71
+ session.session_id,
72
+ q.prompt,
73
+ json.dumps(options_data),
74
+ q.correct_option_id,
75
+ q.explanation,
76
+ ),
77
+ )
78
+ conn.commit()
79
+
80
+ def get_session(self, session_id: str) -> Optional[StagedSession]:
81
+ with self._get_connection() as conn:
82
+ cursor = conn.cursor()
83
+ cursor.execute(
84
+ "SELECT * FROM sessions WHERE session_id = ?",
85
+ (session_id,),
86
+ )
87
+ row = cursor.fetchone()
88
+ if not row:
89
+ return None
90
+ return StagedSession(
91
+ session_id=row["session_id"],
92
+ diff_text=row["diff_text"],
93
+ diff_hash=row["diff_hash"],
94
+ description=row["description"],
95
+ status=SessionStatus(row["status"]),
96
+ created_at=row["created_at"],
97
+ expires_at=row["expires_at"],
98
+ capability_token=row["capability_token"],
99
+ )
100
+
101
+ def get_latest_pending_session(self) -> Optional[StagedSession]:
102
+ with self._get_connection() as conn:
103
+ cursor = conn.cursor()
104
+ cursor.execute(
105
+ """
106
+ SELECT * FROM sessions
107
+ WHERE status = ? AND expires_at > ?
108
+ ORDER BY created_at DESC LIMIT 1
109
+ """,
110
+ (SessionStatus.PENDING_QUIZ.value, time.time()),
111
+ )
112
+ row = cursor.fetchone()
113
+ if not row:
114
+ return None
115
+ return StagedSession(
116
+ session_id=row["session_id"],
117
+ diff_text=row["diff_text"],
118
+ diff_hash=row["diff_hash"],
119
+ description=row["description"],
120
+ status=SessionStatus(row["status"]),
121
+ created_at=row["created_at"],
122
+ expires_at=row["expires_at"],
123
+ capability_token=row["capability_token"],
124
+ )
125
+
126
+ def get_questions_for_session(self, session_id: str) -> List[QuizQuestion]:
127
+ with self._get_connection() as conn:
128
+ cursor = conn.cursor()
129
+ cursor.execute(
130
+ "SELECT * FROM questions WHERE session_id = ?",
131
+ (session_id,),
132
+ )
133
+ rows = cursor.fetchall()
134
+ questions = []
135
+ for r in rows:
136
+ opts = [QuizOption(**opt) for opt in json.loads(r["options_json"])]
137
+ questions.append(
138
+ QuizQuestion(
139
+ id=r["id"],
140
+ prompt=r["prompt"],
141
+ options=opts,
142
+ correct_option_id=r["correct_option_id"],
143
+ explanation=r["explanation"],
144
+ )
145
+ )
146
+ return questions
147
+
148
+ def update_session_status(
149
+ self, session_id: str, status: SessionStatus, token: Optional[str] = None
150
+ ) -> None:
151
+ with self._get_connection() as conn:
152
+ cursor = conn.cursor()
153
+ if token is not None:
154
+ cursor.execute(
155
+ "UPDATE sessions SET status = ?, capability_token = ? WHERE session_id = ?",
156
+ (status.value, token, session_id),
157
+ )
158
+ else:
159
+ cursor.execute(
160
+ "UPDATE sessions SET status = ? WHERE session_id = ?",
161
+ (status.value, session_id),
162
+ )
163
+ conn.commit()
164
+
165
+ def find_valid_approved_session(self, diff_hash: str) -> Optional[StagedSession]:
166
+ now = time.time()
167
+ with self._get_connection() as conn:
168
+ cursor = conn.cursor()
169
+ cursor.execute(
170
+ """
171
+ SELECT * FROM sessions
172
+ WHERE diff_hash = ? AND status = ? AND expires_at > ? AND capability_token IS NOT NULL
173
+ ORDER BY created_at DESC LIMIT 1
174
+ """,
175
+ (diff_hash, SessionStatus.APPROVED.value, now),
176
+ )
177
+ row = cursor.fetchone()
178
+ if not row:
179
+ return None
180
+ return StagedSession(
181
+ session_id=row["session_id"],
182
+ diff_text=row["diff_text"],
183
+ diff_hash=row["diff_hash"],
184
+ description=row["description"],
185
+ status=SessionStatus(row["status"]),
186
+ created_at=row["created_at"],
187
+ expires_at=row["expires_at"],
188
+ capability_token=row["capability_token"],
189
+ )
190
+
191
+ def consume_session_token(self, session_id: str) -> bool:
192
+ with self._get_connection() as conn:
193
+ cursor = conn.cursor()
194
+ cursor.execute(
195
+ "UPDATE sessions SET status = ? WHERE session_id = ? AND status = ?",
196
+ (SessionStatus.CONSUMED.value, session_id, SessionStatus.APPROVED.value),
197
+ )
198
+ conn.commit()
199
+ return cursor.rowcount > 0
200
+
201
+ def list_sessions(self, limit: int = 20) -> List[StagedSession]:
202
+ with self._get_connection() as conn:
203
+ cursor = conn.cursor()
204
+ cursor.execute(
205
+ "SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?",
206
+ (limit,),
207
+ )
208
+ rows = cursor.fetchall()
209
+ return [
210
+ StagedSession(
211
+ session_id=r["session_id"],
212
+ diff_text=r["diff_text"],
213
+ diff_hash=r["diff_hash"],
214
+ description=r["description"],
215
+ status=SessionStatus(r["status"]),
216
+ created_at=r["created_at"],
217
+ expires_at=r["expires_at"],
218
+ capability_token=r["capability_token"],
219
+ )
220
+ for r in rows
221
+ ]
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+
5
+ INIT_SCHEMA_SQL = """
6
+ PRAGMA journal_mode=WAL;
7
+ PRAGMA busy_timeout=5000;
8
+ PRAGMA foreign_keys=ON;
9
+
10
+ CREATE TABLE IF NOT EXISTS sessions (
11
+ session_id TEXT PRIMARY KEY,
12
+ diff_text TEXT NOT NULL,
13
+ diff_hash TEXT NOT NULL,
14
+ description TEXT DEFAULT '',
15
+ status TEXT NOT NULL,
16
+ created_at REAL NOT NULL,
17
+ expires_at REAL NOT NULL,
18
+ capability_token TEXT DEFAULT NULL
19
+ );
20
+
21
+ CREATE TABLE IF NOT EXISTS questions (
22
+ id TEXT PRIMARY KEY,
23
+ session_id TEXT NOT NULL,
24
+ prompt TEXT NOT NULL,
25
+ options_json TEXT NOT NULL,
26
+ correct_option_id TEXT NOT NULL,
27
+ explanation TEXT DEFAULT '',
28
+ FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
32
+ CREATE INDEX IF NOT EXISTS idx_sessions_diff_hash ON sessions(diff_hash);
33
+ CREATE INDEX IF NOT EXISTS idx_questions_session ON questions(session_id);
34
+ """
35
+
36
+
37
+ def init_db(conn: sqlite3.Connection) -> None:
38
+ """Initialize database tables, pragmas, and indexes."""
39
+ conn.executescript(INIT_SCHEMA_SQL)
40
+ conn.commit()
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List
5
+
6
+ from codevoight.core.models import QuizQuestion, QuizResult, QuizSubmission
7
+
8
+
9
+ class BaseQuizGenerator(ABC):
10
+ @abstractmethod
11
+ def generate_quiz(self, diff_text: str, description: str = "") -> List[QuizQuestion]:
12
+ """Generate comprehension questions based on the diff and optional description."""
13
+ pass
14
+
15
+
16
+ class BaseQuizEvaluator(ABC):
17
+ @abstractmethod
18
+ def evaluate(self, submission: QuizSubmission, questions: List[QuizQuestion]) -> QuizResult:
19
+ """Evaluate developer answers and determine pass/fail with feedback."""
20
+ pass
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import uuid
5
+ from typing import List, Set
6
+ from codevoight.core.models import (
7
+ QuizOption,
8
+ QuizQuestion,
9
+ QuizResult,
10
+ QuizSubmission,
11
+ )
12
+ from codevoight.generators.base import BaseQuizEvaluator, BaseQuizGenerator
13
+
14
+
15
+ class DeterministicQuizGenerator(BaseQuizGenerator):
16
+ """Zero-dependency, offline deterministic quiz generator based on AST/diff heuristics."""
17
+
18
+ def generate_quiz(self, diff_text: str, description: str = "") -> List[QuizQuestion]:
19
+ questions: List[QuizQuestion] = []
20
+ files = self._extract_filenames(diff_text)
21
+ added_lines, removed_lines = self._extract_added_removed(diff_text)
22
+ symbols = self._extract_symbols(diff_text)
23
+
24
+ # Question 1: File targeting
25
+ if files:
26
+ primary_file = files[0]
27
+ options = [
28
+ QuizOption(id="A", text=f"Modifies or introduces `{primary_file}`"),
29
+ QuizOption(id="B", text=f"Deletes all tests in `tests/test_legacy.py`"),
30
+ QuizOption(id="C", text=f"Replaces deployment manifests in `.k8s/`"),
31
+ QuizOption(id="D", text=f"Only updates documentation in `docs/architecture.md`"),
32
+ ]
33
+ questions.append(
34
+ QuizQuestion(
35
+ id=str(uuid.uuid4())[:8],
36
+ prompt=f"Which target file is modified in this staged diff?",
37
+ options=options,
38
+ correct_option_id="A",
39
+ explanation=f"The diff explicitly modifies `{primary_file}`.",
40
+ )
41
+ )
42
+
43
+ # Question 2: Symbol / Function change
44
+ if symbols:
45
+ primary_symbol = symbols[0]
46
+ options = [
47
+ QuizOption(id="A", text=f"Introduces or modifies `{primary_symbol}`"),
48
+ QuizOption(id="B", text=f"Deprecated `{primary_symbol}_legacy`"),
49
+ QuizOption(id="C", text=f"Removes `{primary_symbol}` completely"),
50
+ QuizOption(id="D", text="Does not touch any function or class definitions"),
51
+ ]
52
+ questions.append(
53
+ QuizQuestion(
54
+ id=str(uuid.uuid4())[:8],
55
+ prompt="Which symbol or function definition is directly affected by this diff?",
56
+ options=options,
57
+ correct_option_id="A",
58
+ explanation=f"The diff contains changes to `{primary_symbol}`.",
59
+ )
60
+ )
61
+ else:
62
+ # Fallback based on added content length / keywords
63
+ add_count = len(added_lines)
64
+ rem_count = len(removed_lines)
65
+ correct_summary = f"Adds ~{add_count} lines and removes ~{rem_count} lines"
66
+ options = [
67
+ QuizOption(id="A", text=correct_summary),
68
+ QuizOption(id="B", text="Deletes the entire repository database"),
69
+ QuizOption(id="C", text="Performs a full rollback to commit HEAD~10"),
70
+ QuizOption(id="D", text="Changes only binary asset files"),
71
+ ]
72
+ questions.append(
73
+ QuizQuestion(
74
+ id=str(uuid.uuid4())[:8],
75
+ prompt="What is the net line modification profile of this change?",
76
+ options=options,
77
+ correct_option_id="A",
78
+ explanation=f"The diff consists of {add_count} additions and {rem_count} deletions.",
79
+ )
80
+ )
81
+
82
+ # Question 3: Semantic intent or condition
83
+ has_error_handling = any(
84
+ re.search(r"\b(raise|try|except|throw|catch|error|Err)\b", line, re.I)
85
+ for line in added_lines
86
+ )
87
+ has_imports = any(
88
+ re.search(r"^\s*(import|from|require|include)\b", line)
89
+ for line in added_lines
90
+ )
91
+
92
+ if has_error_handling:
93
+ options = [
94
+ QuizOption(id="A", text="Adds error handling, exception guards, or validation"),
95
+ QuizOption(id="B", text="Disables security assertions across all endpoints"),
96
+ QuizOption(id="C", text="Swallows exceptions with silent pass statements"),
97
+ QuizOption(id="D", text="Removes runtime input validation"),
98
+ ]
99
+ questions.append(
100
+ QuizQuestion(
101
+ id=str(uuid.uuid4())[:8],
102
+ prompt="What validation or error handling behavior is added in this diff?",
103
+ options=options,
104
+ correct_option_id="A",
105
+ explanation="The diff introduces explicit exception handling or validation guards.",
106
+ )
107
+ )
108
+ elif has_imports:
109
+ options = [
110
+ QuizOption(id="A", text="Imports or imports from additional modules/libraries"),
111
+ QuizOption(id="B", text="Uninstalls all third-party dependencies"),
112
+ QuizOption(id="C", text="Converts Python code to C++ bindings"),
113
+ QuizOption(id="D", text="Disables external module resolution"),
114
+ ]
115
+ questions.append(
116
+ QuizQuestion(
117
+ id=str(uuid.uuid4())[:8],
118
+ prompt="What dependency or module change is present in this diff?",
119
+ options=options,
120
+ correct_option_id="A",
121
+ explanation="New import statements are introduced in the staged change.",
122
+ )
123
+ )
124
+ else:
125
+ options = [
126
+ QuizOption(id="A", text=f"Implements: {description or 'staged logic update'}"),
127
+ QuizOption(id="B", text="Reverts all recent git commits"),
128
+ QuizOption(id="C", text="Formats code with no functional edits"),
129
+ QuizOption(id="D", text="Increments package version number only"),
130
+ ]
131
+ questions.append(
132
+ QuizQuestion(
133
+ id=str(uuid.uuid4())[:8],
134
+ prompt="What is the primary stated intent of this change?",
135
+ options=options,
136
+ correct_option_id="A",
137
+ explanation="Reflects the staged change intent.",
138
+ )
139
+ )
140
+
141
+ return questions
142
+
143
+ def _extract_filenames(self, diff_text: str) -> List[str]:
144
+ files: List[str] = []
145
+ for line in diff_text.splitlines():
146
+ if line.startswith("+++ b/"):
147
+ files.append(line[6:].strip())
148
+ elif line.startswith("--- a/") and line[6:].strip() not in files:
149
+ f = line[6:].strip()
150
+ if f != "/dev/null":
151
+ files.append(f)
152
+ return list(dict.fromkeys(files))
153
+
154
+ def _extract_added_removed(self, diff_text: str) -> tuple[List[str], List[str]]:
155
+ added = []
156
+ removed = []
157
+ for line in diff_text.splitlines():
158
+ if line.startswith("+") and not line.startswith("+++"):
159
+ added.append(line[1:].strip())
160
+ elif line.startswith("-") and not line.startswith("---"):
161
+ removed.append(line[1:].strip())
162
+ return added, removed
163
+
164
+ def _extract_symbols(self, diff_text: str) -> List[str]:
165
+ symbols: Set[str] = set()
166
+ pattern = re.compile(r"^\+\s*(?:async\s+)?(?:def|class|function|const|let|var)\s+([a-zA-Z0-9_]+)")
167
+ for line in diff_text.splitlines():
168
+ match = pattern.search(line)
169
+ if match:
170
+ symbols.add(match.group(1))
171
+ return sorted(list(symbols))
172
+
173
+
174
+ class DeterministicQuizEvaluator(BaseQuizEvaluator):
175
+ """Deterministic grading engine enforcing 100% threshold by default."""
176
+
177
+ def __init__(self, passing_score: float = 1.0):
178
+ self.passing_score = passing_score
179
+
180
+ def evaluate(self, submission: QuizSubmission, questions: List[QuizQuestion]) -> QuizResult:
181
+ if not questions:
182
+ return QuizResult(
183
+ session_id=submission.session_id,
184
+ passed=True,
185
+ score=1.0,
186
+ total=0,
187
+ correct=0,
188
+ feedback=["No questions required for this diff."],
189
+ )
190
+
191
+ total = len(questions)
192
+ correct = 0
193
+ feedback: List[str] = []
194
+
195
+ for q in questions:
196
+ chosen = submission.answers.get(q.id)
197
+ if chosen == q.correct_option_id:
198
+ correct += 1
199
+ feedback.append(f"Question '{q.prompt}': Correct!")
200
+ else:
201
+ feedback.append(
202
+ f"Question '{q.prompt}': Incorrect. You answered {chosen}, "
203
+ f"expected {q.correct_option_id}. {q.explanation}"
204
+ )
205
+
206
+ score = correct / total
207
+ passed = score >= self.passing_score
208
+
209
+ return QuizResult(
210
+ session_id=submission.session_id,
211
+ passed=passed,
212
+ score=score,
213
+ total=total,
214
+ correct=correct,
215
+ feedback=feedback,
216
+ )
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import List, Optional
5
+
6
+ from codevoight.core.models import QuizQuestion
7
+ from codevoight.generators.base import BaseQuizGenerator
8
+ from codevoight.generators.deterministic import DeterministicQuizGenerator
9
+
10
+
11
+ class LLMQuizGenerator(BaseQuizGenerator):
12
+ """Pluggable LLM generator with automatic fallback to deterministic generator."""
13
+
14
+ def __init__(self, fallback_generator: Optional[BaseQuizGenerator] = None):
15
+ self.fallback = fallback_generator or DeterministicQuizGenerator()
16
+ self.api_key = os.getenv("CODEVOIGHT_LLM_API_KEY") or os.getenv("QUIZGATE_LLM_API_KEY") or os.getenv("CODEGATE_LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
17
+
18
+ def generate_quiz(self, diff_text: str, description: str = "") -> List[QuizQuestion]:
19
+ # If no LLM credentials provided or in offline/test mode, seamlessly use deterministic engine
20
+ if not self.api_key:
21
+ return self.fallback.generate_quiz(diff_text, description)
22
+
23
+ try:
24
+ # Pluggable LLM invocation hook
25
+ # If external network call fails or is not configured, fallback gracefully
26
+ return self._call_llm_api(diff_text, description)
27
+ except Exception:
28
+ return self.fallback.generate_quiz(diff_text, description)
29
+
30
+ def _call_llm_api(self, diff_text: str, description: str = "") -> List[QuizQuestion]:
31
+ # Placeholder for external LLM-as-a-judge provider integration
32
+ # In base install, falls back safely to deterministic rules
33
+ return self.fallback.generate_quiz(diff_text, description)
codevoight/git/hook.py ADDED
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ from pathlib import Path
6
+ from typing import Optional, Tuple
7
+
8
+ from codevoight.core.diff import compute_diff_hash
9
+ from codevoight.core.models import CapabilityToken
10
+ from codevoight.core.security import get_or_create_secret, verify_token
11
+ from codevoight.db.repository import StateRepository
12
+ from codevoight.git.runner import GitRunner
13
+
14
+
15
+ HOOK_SCRIPT = """#!/bin/sh
16
+ # CodeVoight Pre-Commit Verification Hook
17
+ # Enforces human comprehension quiz verification before git commit.
18
+
19
+ if [ -f ".venv/Scripts/python.exe" ]; then
20
+ PYTHON=".venv/Scripts/python.exe"
21
+ elif [ -f ".venv/bin/python" ]; then
22
+ PYTHON=".venv/bin/python"
23
+ elif command -v py >/dev/null 2>&1; then
24
+ PYTHON=py
25
+ elif command -v python3 >/dev/null 2>&1; then
26
+ PYTHON=python3
27
+ elif command -v python >/dev/null 2>&1; then
28
+ PYTHON=python
29
+ else
30
+ echo "[CODEVOIGHT] Error: Python runtime not found in PATH."
31
+ exit 1
32
+ fi
33
+
34
+ $PYTHON -m codevoight.cli verify-commit
35
+ EXIT_CODE=$?
36
+
37
+ if [ $EXIT_CODE -ne 0 ]; then
38
+ echo ""
39
+ echo "[CODEVOIGHT] COMMIT BLOCKED: Staged changes require human comprehension approval."
40
+ echo "[CODEVOIGHT] To approve, run: codevoight quiz"
41
+ echo ""
42
+ exit 1
43
+ fi
44
+
45
+ exit 0
46
+ """
47
+
48
+
49
+ def install_pre_commit_hook(repo_dir: Optional[Path] = None) -> Tuple[bool, str]:
50
+ """Install the pre-commit hook into .git/hooks/pre-commit."""
51
+ runner = GitRunner(repo_dir)
52
+ root = runner.get_repo_root()
53
+ if not root:
54
+ return False, "Not inside a valid Git repository."
55
+
56
+ hooks_dir = root / ".git" / "hooks"
57
+ hooks_dir.mkdir(parents=True, exist_ok=True)
58
+ hook_file = hooks_dir / "pre-commit"
59
+
60
+ hook_file.write_text(HOOK_SCRIPT, encoding="utf-8", newline="\n")
61
+
62
+ # Set executable bit for unix/posix
63
+ try:
64
+ current_stat = os.stat(hook_file)
65
+ os.chmod(hook_file, current_stat.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
66
+ except Exception:
67
+ pass
68
+
69
+ return True, str(hook_file)
70
+
71
+
72
+ def verify_staged_commit(workspace_root: Optional[Path] = None) -> Tuple[bool, str]:
73
+ """Verify that current staged Git changes have a valid, unexpired approval token."""
74
+ runner = GitRunner(workspace_root)
75
+ root = runner.get_repo_root() or (workspace_root.resolve() if workspace_root else None)
76
+ if not root:
77
+ return False, "Not inside a git repository."
78
+
79
+ staged_diff = runner.get_staged_diff()
80
+ if not staged_diff.strip():
81
+ # Nothing staged or empty commit
82
+ return True, "No staged diff to verify."
83
+
84
+ diff_hash = compute_diff_hash(staged_diff)
85
+
86
+ codevoight_dir = root / ".codevoight"
87
+ if not codevoight_dir.exists():
88
+ if (root / ".quizgate").exists():
89
+ codevoight_dir = root / ".quizgate"
90
+ elif (root / ".codegate").exists():
91
+ codevoight_dir = root / ".codegate"
92
+
93
+ db_path = codevoight_dir / "state.db"
94
+ if not db_path.exists():
95
+ return False, "No CodeVoight state found. Run 'codevoight quiz' to approve staged changes."
96
+
97
+ repo = StateRepository(db_path)
98
+ session = repo.find_valid_approved_session(diff_hash)
99
+ if not session or not session.capability_token:
100
+ return False, f"Diff hash {diff_hash[:12]} has not been approved via quiz."
101
+
102
+ # Verify cryptographic signature
103
+ secret_key = get_or_create_secret(codevoight_dir)
104
+ try:
105
+ token = CapabilityToken.parse(session.capability_token)
106
+ except Exception as e:
107
+ return False, f"Invalid token format: {e}"
108
+
109
+ is_valid = verify_token(token, diff_hash, secret_key)
110
+ if not is_valid:
111
+ return False, "Token signature invalid or expired."
112
+
113
+ return True, f"Verified capability token for session {session.session_id}."