graphite-code 0.3.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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """Approval-gated development model routing for Graphite."""
2
+
3
+ from .settings import RoutingSettings, RoutingSettingsError
4
+
5
+ __all__ = ["RoutingSettings", "RoutingSettingsError"]
@@ -0,0 +1,362 @@
1
+ """Default-No consent, HMAC integrity, and single-use quota coordination."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import secrets
9
+ import sqlite3
10
+ from contextlib import closing
11
+ import stat
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Callable, TextIO
16
+
17
+ from .contracts import ApprovalManifest, CliApprovalManifest
18
+ from .route_pool import ApprovedRoutePool
19
+ from .storage import RepositoryStore, StorageError
20
+
21
+ _REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
22
+
23
+
24
+ class ApprovalError(RuntimeError):
25
+ """A stable, path-free approval failure."""
26
+
27
+ def __init__(self, code: str) -> None:
28
+ self.code = code
29
+ super().__init__(code)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SignedApproval:
34
+ manifest: ApprovalManifest | CliApprovalManifest | ApprovedRoutePool
35
+ signature: str
36
+
37
+ def to_dict(self) -> dict[str, object]:
38
+ return {
39
+ "manifest": self.manifest.to_dict(),
40
+ "signature": self.signature,
41
+ }
42
+
43
+
44
+ def approval_prompt(
45
+ *,
46
+ stdin: TextIO,
47
+ stdout: TextIO,
48
+ stdin_is_tty: bool,
49
+ stdout_is_tty: bool,
50
+ json_mode: bool,
51
+ assume_yes: bool,
52
+ ci: bool,
53
+ ) -> bool:
54
+ """Request one explicit interactive approval; every other mode declines."""
55
+ if (
56
+ not stdin_is_tty
57
+ or not stdout_is_tty
58
+ or json_mode
59
+ or assume_yes
60
+ or ci
61
+ ):
62
+ return False
63
+ stdout.write("Approve this development model call? [y/N] ")
64
+ stdout.flush()
65
+ try:
66
+ answer = stdin.readline(16)
67
+ except (EOFError, OSError):
68
+ return False
69
+ return answer.strip().casefold() in {"y", "yes"}
70
+
71
+
72
+ def _canonical_manifest(
73
+ manifest: ApprovalManifest | CliApprovalManifest | ApprovedRoutePool,
74
+ ) -> bytes:
75
+ return json.dumps(
76
+ manifest.to_dict(),
77
+ sort_keys=True,
78
+ separators=(",", ":"),
79
+ ).encode("utf-8")
80
+
81
+
82
+ def _is_reparse(metadata: os.stat_result) -> bool:
83
+ return bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE_POINT)
84
+
85
+
86
+ def _secure_parent(path: Path) -> None:
87
+ try:
88
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
89
+ metadata = path.lstat()
90
+ if stat.S_ISLNK(metadata.st_mode) or _is_reparse(metadata) or not stat.S_ISDIR(metadata.st_mode):
91
+ raise ApprovalError("approval_state_path_invalid")
92
+ if os.name != "nt":
93
+ path.chmod(0o700)
94
+ except ApprovalError:
95
+ raise
96
+ except OSError as exc:
97
+ raise ApprovalError("approval_state_unavailable") from exc
98
+
99
+
100
+ def _load_or_create_key(path: Path, repository_root: Path) -> bytes:
101
+ resolved_parent = path.parent.resolve(strict=False)
102
+ try:
103
+ resolved_parent.relative_to(repository_root)
104
+ except ValueError:
105
+ pass
106
+ else:
107
+ raise ApprovalError("approval_key_path_invalid")
108
+ _secure_parent(path.parent)
109
+ try:
110
+ metadata = path.lstat()
111
+ except FileNotFoundError:
112
+ metadata = None
113
+ except OSError as exc:
114
+ raise ApprovalError("approval_key_unavailable") from exc
115
+ if metadata is None:
116
+ key = secrets.token_bytes(32)
117
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
118
+ try:
119
+ descriptor = os.open(path, flags, 0o600)
120
+ try:
121
+ written = os.write(descriptor, key)
122
+ if written != len(key):
123
+ raise ApprovalError("approval_key_unavailable")
124
+ os.fsync(descriptor)
125
+ finally:
126
+ os.close(descriptor)
127
+ except FileExistsError:
128
+ return _load_or_create_key(path, repository_root)
129
+ except ApprovalError:
130
+ raise
131
+ except OSError as exc:
132
+ raise ApprovalError("approval_key_unavailable") from exc
133
+ if os.name != "nt":
134
+ path.chmod(0o600)
135
+ return key
136
+ if stat.S_ISLNK(metadata.st_mode) or _is_reparse(metadata) or not stat.S_ISREG(metadata.st_mode):
137
+ raise ApprovalError("approval_key_path_invalid")
138
+ if metadata.st_size != 32:
139
+ raise ApprovalError("approval_key_invalid")
140
+ try:
141
+ key = path.read_bytes()
142
+ except OSError as exc:
143
+ raise ApprovalError("approval_key_unavailable") from exc
144
+ if len(key) != 32:
145
+ raise ApprovalError("approval_key_invalid")
146
+ return key
147
+
148
+
149
+ class _MachineQuotaStore:
150
+ def __init__(self, path: Path, repository_root: Path) -> None:
151
+ resolved = path.resolve(strict=False)
152
+ try:
153
+ resolved.relative_to(repository_root)
154
+ except ValueError:
155
+ pass
156
+ else:
157
+ raise ApprovalError("quota_path_invalid")
158
+ self.path = path
159
+ _secure_parent(path.parent)
160
+ try:
161
+ with closing(sqlite3.connect(path, timeout=2.0)) as connection, connection:
162
+ connection.execute("PRAGMA busy_timeout = 2000")
163
+ connection.execute(
164
+ """CREATE TABLE IF NOT EXISTS reservations (
165
+ nonce_hash TEXT PRIMARY KEY,
166
+ token_amount INTEGER NOT NULL,
167
+ status TEXT NOT NULL CHECK(status IN ('reserved', 'released', 'settled')),
168
+ created_at INTEGER NOT NULL
169
+ )"""
170
+ )
171
+ except sqlite3.Error as exc:
172
+ raise ApprovalError("quota_unavailable") from exc
173
+ if os.name != "nt":
174
+ path.chmod(0o600)
175
+
176
+ def reserve(self, nonce_hash: str, amount: int, quota: int, created_at: int) -> None:
177
+ connection: sqlite3.Connection | None = None
178
+ try:
179
+ connection = sqlite3.connect(self.path, timeout=2.0, isolation_level=None)
180
+ connection.execute("PRAGMA busy_timeout = 2000")
181
+ connection.execute("BEGIN IMMEDIATE")
182
+ existing = connection.execute(
183
+ "SELECT status FROM reservations WHERE nonce_hash = ?",
184
+ (nonce_hash,),
185
+ ).fetchone()
186
+ if existing is not None:
187
+ raise ApprovalError("approval_reused")
188
+ total = int(
189
+ connection.execute(
190
+ "SELECT COALESCE(SUM(token_amount), 0) FROM reservations WHERE status = 'reserved'"
191
+ ).fetchone()[0]
192
+ )
193
+ if amount > quota or total + amount > quota:
194
+ raise ApprovalError("budget_exhausted")
195
+ connection.execute(
196
+ "INSERT INTO reservations(nonce_hash, token_amount, status, created_at) VALUES (?, ?, 'reserved', ?)",
197
+ (nonce_hash, amount, created_at),
198
+ )
199
+ connection.commit()
200
+ except ApprovalError:
201
+ if connection is not None and connection.in_transaction:
202
+ connection.rollback()
203
+ raise
204
+ except sqlite3.Error as exc:
205
+ if connection is not None and connection.in_transaction:
206
+ connection.rollback()
207
+ raise ApprovalError("quota_unavailable") from exc
208
+ finally:
209
+ if connection is not None:
210
+ connection.close()
211
+
212
+ def release(self, nonce_hash: str) -> None:
213
+ try:
214
+ with closing(sqlite3.connect(self.path, timeout=2.0)) as connection, connection:
215
+ connection.execute(
216
+ "UPDATE reservations SET status = 'released' WHERE nonce_hash = ? AND status = 'reserved'",
217
+ (nonce_hash,),
218
+ )
219
+ except sqlite3.Error as exc:
220
+ raise ApprovalError("quota_unavailable") from exc
221
+
222
+ def reserved_total(self) -> int:
223
+ try:
224
+ with closing(sqlite3.connect(self.path, timeout=2.0)) as connection, connection:
225
+ return int(
226
+ connection.execute(
227
+ "SELECT COALESCE(SUM(token_amount), 0) FROM reservations WHERE status = 'reserved'"
228
+ ).fetchone()[0]
229
+ )
230
+ except sqlite3.Error as exc:
231
+ raise ApprovalError("quota_unavailable") from exc
232
+
233
+
234
+ class ApprovalAuthority:
235
+ """Issues and consumes signed approvals.
236
+
237
+ HMAC protects cross-file and accidental tampering. A hostile process running
238
+ as the same operating-system user remains outside this trust boundary.
239
+ """
240
+
241
+ def __init__(
242
+ self,
243
+ store: RepositoryStore,
244
+ *,
245
+ key_path: Path,
246
+ quota_path: Path,
247
+ now: Callable[[], int] | None = None,
248
+ ) -> None:
249
+ self.store = store
250
+ self._key = _load_or_create_key(key_path, store.root)
251
+ self._quota = _MachineQuotaStore(quota_path, store.root)
252
+ self._now = now or (lambda: int(time.time()))
253
+
254
+ def issue(
255
+ self,
256
+ manifest: ApprovalManifest | CliApprovalManifest | ApprovedRoutePool,
257
+ *,
258
+ lifecycle_identity_digest: str | None = None,
259
+ capability_snapshot_digest: str | None = None,
260
+ bound_at: int | None = None,
261
+ ) -> SignedApproval:
262
+ lifecycle_values = (
263
+ lifecycle_identity_digest,
264
+ capability_snapshot_digest,
265
+ bound_at,
266
+ )
267
+ if any(value is not None for value in lifecycle_values) and (
268
+ not isinstance(manifest, CliApprovalManifest)
269
+ or lifecycle_identity_digest is None
270
+ or capability_snapshot_digest != manifest.capability_snapshot_digest
271
+ or bound_at is None
272
+ ):
273
+ raise ApprovalError("approval_lifecycle_binding_invalid")
274
+ payload = _canonical_manifest(manifest)
275
+ manifest_hash = hashlib.sha256(payload).hexdigest()
276
+ nonce_hash = hashlib.sha256(manifest.nonce.encode("utf-8")).hexdigest()
277
+ signature = hmac.new(self._key, payload, hashlib.sha256).hexdigest()
278
+ try:
279
+ self.store.save_approval_record(
280
+ approval_id=manifest.approval_id,
281
+ task_id=None,
282
+ decision_id=None,
283
+ nonce_hash=nonce_hash,
284
+ manifest_hash=manifest_hash,
285
+ expires_at=manifest.expires_at,
286
+ reserved_tokens=manifest.max_input_tokens + manifest.max_output_tokens,
287
+ )
288
+ except StorageError as exc:
289
+ raise ApprovalError(exc.code) from exc
290
+ if any(value is not None for value in lifecycle_values):
291
+ assert isinstance(manifest, CliApprovalManifest)
292
+ assert lifecycle_identity_digest is not None
293
+ assert capability_snapshot_digest is not None
294
+ assert bound_at is not None
295
+ try:
296
+ self.store.save_lifecycle_approval_binding(
297
+ approval_id=manifest.approval_id,
298
+ capability_snapshot_digest=capability_snapshot_digest,
299
+ lifecycle_identity_digest=lifecycle_identity_digest,
300
+ bound_at=bound_at,
301
+ )
302
+ except (StorageError, ValueError):
303
+ raise ApprovalError("approval_lifecycle_binding_invalid") from None
304
+ return SignedApproval(manifest, signature)
305
+
306
+ def verify(
307
+ self,
308
+ signed: SignedApproval,
309
+ current_manifest: ApprovalManifest | CliApprovalManifest | ApprovedRoutePool,
310
+ ) -> None:
311
+ if not isinstance(signed, SignedApproval):
312
+ raise ApprovalError("approval_invalid")
313
+ signed_payload = _canonical_manifest(signed.manifest)
314
+ current_payload = _canonical_manifest(current_manifest)
315
+ if not hmac.compare_digest(signed_payload, current_payload):
316
+ raise ApprovalError("approval_manifest_changed")
317
+ expected = hmac.new(self._key, signed_payload, hashlib.sha256).hexdigest()
318
+ if not hmac.compare_digest(expected, signed.signature):
319
+ raise ApprovalError("approval_signature_invalid")
320
+
321
+ def consume(
322
+ self,
323
+ signed: SignedApproval,
324
+ current_manifest: ApprovalManifest | CliApprovalManifest | ApprovedRoutePool,
325
+ *,
326
+ repository_quota_tokens: int,
327
+ machine_quota_tokens: int,
328
+ lifecycle_identity_digest: str | None = None,
329
+ capability_snapshot_digest: str | None = None,
330
+ ) -> None:
331
+ self.verify(signed, current_manifest)
332
+ if lifecycle_identity_digest is not None or capability_snapshot_digest is not None:
333
+ try:
334
+ binding = self.store.lifecycle_approval_binding_details(
335
+ current_manifest.approval_id
336
+ )
337
+ except (StorageError, ValueError):
338
+ raise ApprovalError("approval_lifecycle_stale") from None
339
+ if not isinstance(current_manifest, CliApprovalManifest) or lifecycle_identity_digest is None or capability_snapshot_digest != current_manifest.capability_snapshot_digest or binding != (capability_snapshot_digest, lifecycle_identity_digest):
340
+ raise ApprovalError("approval_lifecycle_stale")
341
+ now = self._now()
342
+ if now >= current_manifest.expires_at:
343
+ raise ApprovalError("approval_expired")
344
+ amount = current_manifest.max_input_tokens + current_manifest.max_output_tokens
345
+ nonce_hash = hashlib.sha256(current_manifest.nonce.encode("utf-8")).hexdigest()
346
+ manifest_hash = hashlib.sha256(_canonical_manifest(current_manifest)).hexdigest()
347
+ self._quota.reserve(nonce_hash, amount, machine_quota_tokens, now)
348
+ try:
349
+ self.store.consume_approval_record(
350
+ approval_id=current_manifest.approval_id,
351
+ nonce_hash=nonce_hash,
352
+ manifest_hash=manifest_hash,
353
+ now=now,
354
+ token_amount=amount,
355
+ repository_quota=repository_quota_tokens,
356
+ )
357
+ except StorageError as exc:
358
+ self._quota.release(nonce_hash)
359
+ raise ApprovalError(exc.code) from exc
360
+
361
+ def machine_reserved_token_total(self) -> int:
362
+ return self._quota.reserved_total()
@@ -0,0 +1,169 @@
1
+ """Deterministic task classification from requests and bounded graph evidence."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from collections import deque
7
+ from typing import Any, Final
8
+
9
+ from .contracts import RiskTier, TaskCategory, TaskProfile, TaskRequest
10
+
11
+ MAX_IMPACT_NODES: Final = 10_000
12
+
13
+ _CATEGORY_RULES: tuple[tuple[TaskCategory, tuple[str, ...]], ...] = (
14
+ (TaskCategory.TENANT_ISOLATION, ("tenant isolation", "cross-tenant", "multi-tenant")),
15
+ (TaskCategory.AUTHORIZATION, ("authorization", "authorisation", "permission", "role access")),
16
+ (TaskCategory.AUTHENTICATION, ("authentication", "login", "sign in", "oauth", "session")),
17
+ (TaskCategory.MIGRATION, ("database migration", "schema migration", "rollback migration")),
18
+ (TaskCategory.DEPLOYMENT, ("deployment", "deploy", "release pipeline", "production release")),
19
+ (TaskCategory.INFRASTRUCTURE, ("infrastructure", "terraform", "kubernetes", "cloud provision")),
20
+ (TaskCategory.CONCURRENCY, ("concurrent", "concurrency", "race condition", "deadlock", "transaction race")),
21
+ (TaskCategory.FINANCIAL, ("financial", "money", "mortgage", "payment", "billing", "invoice")),
22
+ (TaskCategory.LEGAL, ("legal", "contract clause", "compliance policy")),
23
+ (TaskCategory.ARCHITECTURE, ("architecture", "system design", "service boundary")),
24
+ (TaskCategory.REFACTOR, ("refactor", "restructure", "extract module")),
25
+ (TaskCategory.FEATURE, ("feature", "add a", "implement", "customer search", "endpoint")),
26
+ (TaskCategory.ISOLATED_CODE, ("isolated", "formatting helper", "small helper", "typo fix")),
27
+ (TaskCategory.DOCUMENTATION, ("documentation", "readme", "guide", "docs")),
28
+ )
29
+
30
+ _CATEGORY_RISK: Final[dict[TaskCategory, RiskTier]] = {
31
+ TaskCategory.DOCUMENTATION: RiskTier.LOW,
32
+ TaskCategory.ISOLATED_CODE: RiskTier.LOW,
33
+ TaskCategory.FEATURE: RiskTier.MEDIUM,
34
+ TaskCategory.REFACTOR: RiskTier.MEDIUM,
35
+ TaskCategory.ARCHITECTURE: RiskTier.HIGH,
36
+ TaskCategory.AUTHENTICATION: RiskTier.HIGH,
37
+ TaskCategory.AUTHORIZATION: RiskTier.HIGH,
38
+ TaskCategory.TENANT_ISOLATION: RiskTier.HIGH,
39
+ TaskCategory.MIGRATION: RiskTier.HIGH,
40
+ TaskCategory.DEPLOYMENT: RiskTier.HIGH,
41
+ TaskCategory.INFRASTRUCTURE: RiskTier.HIGH,
42
+ TaskCategory.CONCURRENCY: RiskTier.HIGH,
43
+ TaskCategory.FINANCIAL: RiskTier.HIGH,
44
+ TaskCategory.LEGAL: RiskTier.HIGH,
45
+ TaskCategory.UNKNOWN: RiskTier.HIGH,
46
+ }
47
+ _RISK_ORDER = {RiskTier.LOW: 0, RiskTier.MEDIUM: 1, RiskTier.HIGH: 2}
48
+
49
+
50
+ def _detect_category(objective: str) -> TaskCategory:
51
+ normalized = " ".join(objective.casefold().split())
52
+ for category, keywords in _CATEGORY_RULES:
53
+ if any(keyword in normalized for keyword in keywords):
54
+ return category
55
+ return TaskCategory.UNKNOWN
56
+
57
+
58
+ def _target_nodes(graph: Any, targets: tuple[str, ...]) -> list[str]:
59
+ selected: list[str] = []
60
+ target_set = set(targets)
61
+ try:
62
+ nodes = graph.nodes(data=True)
63
+ except (AttributeError, TypeError):
64
+ return []
65
+ for node_id, data in nodes:
66
+ if isinstance(data, dict) and data.get("source_file") in target_set:
67
+ selected.append(str(node_id))
68
+ return sorted(set(selected))
69
+
70
+
71
+ def _bounded_impact(graph: Any, starts: list[str]) -> tuple[set[str], bool]:
72
+ visited = set(starts)
73
+ queue = deque(starts)
74
+ truncated = False
75
+ while queue:
76
+ node = queue.popleft()
77
+ try:
78
+ neighbors = set(graph.predecessors(node)) | set(graph.successors(node))
79
+ except (AttributeError, KeyError):
80
+ continue
81
+ for neighbor in sorted(neighbors, key=str):
82
+ normalized = str(neighbor)
83
+ if normalized in visited:
84
+ continue
85
+ if len(visited) >= MAX_IMPACT_NODES:
86
+ truncated = True
87
+ return visited, truncated
88
+ visited.add(normalized)
89
+ queue.append(normalized)
90
+ return visited, truncated
91
+
92
+
93
+ def classify_task(request: TaskRequest, graph: Any) -> TaskProfile:
94
+ """Classify a request using fixed rules and bounded graph features."""
95
+ detected = _detect_category(request.objective)
96
+ category = detected
97
+ detected_risk = _CATEGORY_RISK[detected]
98
+ if request.category_hint is not None:
99
+ hint_risk = _CATEGORY_RISK[request.category_hint]
100
+ if _RISK_ORDER[hint_risk] > _RISK_ORDER[detected_risk]:
101
+ category = request.category_hint
102
+ risk = _CATEGORY_RISK[category]
103
+
104
+ starts = _target_nodes(graph, request.targets)
105
+ impacted, truncated = _bounded_impact(graph, starts)
106
+ flags: list[str] = []
107
+ communities: set[object] = set()
108
+ tests_nearby = False
109
+ for node in sorted(impacted):
110
+ try:
111
+ data = graph.nodes[node]
112
+ except (AttributeError, KeyError):
113
+ continue
114
+ source_file = data.get("source_file") if isinstance(data, dict) else None
115
+ if isinstance(source_file, str) and (
116
+ source_file.startswith("tests/")
117
+ or ".test." in source_file
118
+ or ".spec." in source_file
119
+ or source_file.endswith("_test.py")
120
+ ):
121
+ tests_nearby = True
122
+ if isinstance(data, dict) and data.get("community") is not None:
123
+ communities.add(data["community"])
124
+ if tests_nearby:
125
+ flags.append("tests_nearby")
126
+ if len(communities) > 1:
127
+ flags.append("community_crossing")
128
+ if len(impacted) >= 25 or truncated:
129
+ flags.append("broad_impact")
130
+ if risk is RiskTier.LOW:
131
+ risk = RiskTier.MEDIUM
132
+ if detected is TaskCategory.UNKNOWN:
133
+ flags.append("unknown_scope")
134
+ if risk is RiskTier.HIGH:
135
+ flags.append("high_risk_category")
136
+
137
+ verification = ["tests"]
138
+ if risk is RiskTier.HIGH:
139
+ verification.extend(("security_review", "manual_frontier_review"))
140
+ if category is TaskCategory.MIGRATION:
141
+ verification.append("rollback_test")
142
+ complexity = min(
143
+ 10,
144
+ max(1, 1 + len(request.targets) + len(impacted) // 5 + _RISK_ORDER[risk] * 2),
145
+ )
146
+ task_material = json.dumps(
147
+ {
148
+ "objective": request.objective,
149
+ "targets": list(request.targets),
150
+ "category": category.value,
151
+ "risk": risk.value,
152
+ },
153
+ sort_keys=True,
154
+ separators=(",", ":"),
155
+ ).encode("utf-8")
156
+ task_id = "task-" + hashlib.sha256(task_material).hexdigest()[:24]
157
+ context_requirements = ["explicit_targets"]
158
+ if impacted:
159
+ context_requirements.append("dependency_neighbors")
160
+ return TaskProfile(
161
+ task_id=task_id,
162
+ category=category,
163
+ risk=risk,
164
+ complexity=complexity,
165
+ impact_radius=len(impacted),
166
+ risk_flags=tuple(flags),
167
+ context_requirements=tuple(context_requirements),
168
+ verification_requirements=tuple(verification),
169
+ )