devcouncil 0.1.1 → 0.3.0
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/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -5,16 +5,37 @@ Replaces scattered yaml.safe_load() calls with a single validated config service
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import logging
|
|
8
9
|
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
9
13
|
from pathlib import Path
|
|
10
|
-
from typing import Dict, List
|
|
14
|
+
from typing import Dict, List, Optional, Tuple
|
|
11
15
|
|
|
12
|
-
from pydantic import BaseModel, Field
|
|
16
|
+
from pydantic import BaseModel, Field, field_validator
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
13
19
|
|
|
14
20
|
|
|
15
21
|
class ModelRoleConfig(BaseModel):
|
|
16
22
|
model: str
|
|
17
23
|
temperature: float = 0.0
|
|
24
|
+
# Optional per-role provider override. When unset, the role uses
|
|
25
|
+
# ``models.provider``. Lets a single run route some roles to one provider
|
|
26
|
+
# (e.g. planning on OpenRouter) and others to another (e.g. live review on
|
|
27
|
+
# local Ollama). Validated/normalized against the supported provider list.
|
|
28
|
+
provider: str | None = None
|
|
29
|
+
|
|
30
|
+
@field_validator("provider")
|
|
31
|
+
@classmethod
|
|
32
|
+
def _normalize_provider(cls, value: str | None) -> str | None:
|
|
33
|
+
if value is None:
|
|
34
|
+
return None
|
|
35
|
+
# Lazy import avoids a circular import (llm.provider imports app.config).
|
|
36
|
+
from devcouncil.llm.provider import validate_model_provider
|
|
37
|
+
|
|
38
|
+
return validate_model_provider(value)
|
|
18
39
|
|
|
19
40
|
|
|
20
41
|
class ModelsConfig(BaseModel):
|
|
@@ -34,6 +55,79 @@ class CommandsConfig(BaseModel):
|
|
|
34
55
|
typecheck: List[str] = Field(default_factory=list)
|
|
35
56
|
|
|
36
57
|
|
|
58
|
+
class VerificationSandboxConfig(BaseModel):
|
|
59
|
+
docker_image: str = "python:3.12-slim"
|
|
60
|
+
docker_setup_commands: List[str] = Field(default_factory=list)
|
|
61
|
+
nix_flake_attr: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DiffCoverageConfig(BaseModel):
|
|
65
|
+
"""Diff↔coverage gating: prove the *changed* lines were exercised by tests.
|
|
66
|
+
|
|
67
|
+
``measure`` runs the diff-coverage analysis and records it as evidence (and a
|
|
68
|
+
non-blocking signal) whenever the target repo's coverage tooling is present.
|
|
69
|
+
``enforce`` promotes an unexercised diff to a *blocking* gap. Enforcement is
|
|
70
|
+
off by default so the signal is visible before it ever gates — a passing test
|
|
71
|
+
that does not touch the new code is surfaced first, then teams opt in to
|
|
72
|
+
blocking. ``min_ratio`` of 0.0 means "require at least one changed executable
|
|
73
|
+
line to be exercised"; a higher value demands that fraction of changed lines.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
measure: bool = True
|
|
77
|
+
enforce: bool = False
|
|
78
|
+
min_ratio: float = 0.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AcceptanceCheckConfig(BaseModel):
|
|
82
|
+
"""Tuning for DevCouncil's per-criterion compiled acceptance checks.
|
|
83
|
+
|
|
84
|
+
These default to single-shot behavior (``samples=1``, ``repair_attempts=1``)
|
|
85
|
+
so a strong cloud model is unaffected. They exist to make a WEAK/LOCAL model
|
|
86
|
+
(e.g. an Ollama reviewer) trustworthy: such a model frequently emits a check
|
|
87
|
+
that does not run (wrong import, broken one-liner) — recorded as ``incomplete``
|
|
88
|
+
— or a single mis-asserting check that false-``blocked`` correct code.
|
|
89
|
+
|
|
90
|
+
- ``repair_attempts``: when a compiled check FAILS TO RUN (malformed/unrunnable,
|
|
91
|
+
proves nothing), feed the error back and regenerate the COMMAND up to this many
|
|
92
|
+
times. Safe by construction — a check that never ran cannot weaken the gate.
|
|
93
|
+
- ``samples``: generate this many INDEPENDENT checks per criterion and decide by
|
|
94
|
+
majority vote (proven iff a strict majority pass; unanimous-fail blocks; a split
|
|
95
|
+
stays unproven with a non-blocking advisory). Local sampling is cost-free, so
|
|
96
|
+
raising this (e.g. 3) outvotes a single mis-generated check without ever
|
|
97
|
+
auto-passing a real defect. ``1`` reproduces today's single-check behavior.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
samples: int = 1
|
|
101
|
+
repair_attempts: int = 1
|
|
102
|
+
# Compile one criterion per model call instead of batching them all into a single
|
|
103
|
+
# prompt. A weak/local model batching N criteria into one JSON routinely omits or
|
|
104
|
+
# mis-attributes some (a false "incomplete"); a focused single-criterion prompt is far
|
|
105
|
+
# more reliable. Costs N× the calls — cheap on a local monitor, so opt-in. Off keeps
|
|
106
|
+
# the single batched call.
|
|
107
|
+
per_criterion: bool = False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ReviewerCheckConfig(BaseModel):
|
|
111
|
+
"""Self-consistency voting for the LLM live reviewer.
|
|
112
|
+
|
|
113
|
+
A weak/local reviewer can emit a lone, mis-calibrated "Critical Issues" verdict that
|
|
114
|
+
falsely BLOCKS (the live card becomes a blocking gap). Sampling ``samples`` independent
|
|
115
|
+
reviews and majority-voting the verdict outvotes a single bad judgment: the gate only
|
|
116
|
+
escalates to the blocking verdict when a strict majority agree; a split de-escalates to
|
|
117
|
+
the non-blocking "Concerns". ``samples=1`` reproduces today's single-review behavior, so
|
|
118
|
+
a strong cloud model is unaffected. Local sampling is cost-free — raise it (e.g. 3) there.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
samples: int = 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class VerificationConfig(BaseModel):
|
|
125
|
+
sandbox: VerificationSandboxConfig = Field(default_factory=VerificationSandboxConfig)
|
|
126
|
+
diff_coverage: DiffCoverageConfig = Field(default_factory=DiffCoverageConfig)
|
|
127
|
+
acceptance_checks: AcceptanceCheckConfig = Field(default_factory=AcceptanceCheckConfig)
|
|
128
|
+
reviewer_checks: ReviewerCheckConfig = Field(default_factory=ReviewerCheckConfig)
|
|
129
|
+
|
|
130
|
+
|
|
37
131
|
class GatesConfig(BaseModel):
|
|
38
132
|
require_clean_git_before_task: bool = True
|
|
39
133
|
block_orphan_diffs: bool = True
|
|
@@ -48,6 +142,23 @@ class ExecutionConfig(BaseModel):
|
|
|
48
142
|
max_repair_attempts: int = 3
|
|
49
143
|
checkpoint_before_each_task: bool = True
|
|
50
144
|
command_timeout: int = 300
|
|
145
|
+
stream_cli_output: bool = False
|
|
146
|
+
# Default lifetime of an MCP task lease. A crashed/disconnected agent's lease
|
|
147
|
+
# auto-expires after this, so the task frees up without a human running force.
|
|
148
|
+
lease_ttl_seconds: int = 1800
|
|
149
|
+
# When true, the post-task coding-CLI hook runs deterministic verification of the
|
|
150
|
+
# active task (and records gaps) instead of only printing a reminder. Off by default
|
|
151
|
+
# so hooks stay fast/cheap unless a team opts in.
|
|
152
|
+
verify_on_post_task: bool = False
|
|
153
|
+
cursor_resume_mode: str = "off"
|
|
154
|
+
coding_cli_probe_order: List[str] = Field(default_factory=list)
|
|
155
|
+
# Opt-in scope gate for executors WITHOUT a pre-write hook (CLI subprocesses that write
|
|
156
|
+
# directly to disk). When true, DevCouncil re-checks every file a coding-CLI subprocess
|
|
157
|
+
# changed against the task's authorization right after it exits and REVERTS any the task
|
|
158
|
+
# did not allow — so unplanned drift never reaches the verify gate or a commit, instead
|
|
159
|
+
# of only being flagged post-verify by orphan_diff. Off by default (it reverts the
|
|
160
|
+
# agent's writes; teams opt in once their plans declare planned_files reliably).
|
|
161
|
+
enforce_file_scope_pre_verify: bool = False
|
|
51
162
|
|
|
52
163
|
|
|
53
164
|
class PrivacyConfig(BaseModel):
|
|
@@ -75,10 +186,106 @@ class LiveReviewIntegrationConfig(BaseModel):
|
|
|
75
186
|
default_client: str = "claude"
|
|
76
187
|
|
|
77
188
|
|
|
189
|
+
class WarpIntegrationConfig(BaseModel):
|
|
190
|
+
enabled: bool = False
|
|
191
|
+
command: str = "oz"
|
|
192
|
+
run_mode: str = "local"
|
|
193
|
+
mcp_config_path: str = ".devcouncil/integrations/warp-mcp.json"
|
|
194
|
+
profile: str | None = None
|
|
195
|
+
model: str | None = None
|
|
196
|
+
environment: str | None = None
|
|
197
|
+
share: List[str] = Field(default_factory=list)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class OpenCodeIntegrationConfig(BaseModel):
|
|
201
|
+
enabled: bool = False
|
|
202
|
+
config_path: str = "opencode.json"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class AntigravityIntegrationConfig(BaseModel):
|
|
206
|
+
enabled: bool = False
|
|
207
|
+
mcp_config_path: str = ".agents/mcp_config.json"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class CursorIntegrationConfig(BaseModel):
|
|
211
|
+
enabled: bool = False
|
|
212
|
+
config_path: str = ".cursor/mcp.json"
|
|
213
|
+
hooks_path: str = ".cursor/hooks.json"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class AiderIntegrationConfig(BaseModel):
|
|
217
|
+
enabled: bool = False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class CliAgentProfileConfig(BaseModel):
|
|
221
|
+
description: str = ""
|
|
222
|
+
timeout_seconds: int | None = None
|
|
223
|
+
prompt_preamble: str = ""
|
|
224
|
+
require_explicit_confirmation: bool = False
|
|
225
|
+
# Per-profile CLI containment overrides. Empty/None reproduce today's behavior
|
|
226
|
+
# exactly so a profile that only sets a prompt preamble is a no-op on the
|
|
227
|
+
# subprocess invocation. ``extra_args`` are appended verbatim to the resolved
|
|
228
|
+
# command, ``permission_mode`` is translated into the right per-CLI flag where
|
|
229
|
+
# known (and overly-permissive flags are dropped for stricter modes), and
|
|
230
|
+
# ``model`` overrides the model flag for CLIs that accept one.
|
|
231
|
+
extra_args: List[str] = Field(default_factory=list)
|
|
232
|
+
permission_mode: str | None = None
|
|
233
|
+
model: str | None = None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class CustomCliAgentConfig(BaseModel):
|
|
237
|
+
command: str
|
|
238
|
+
args: List[str] = Field(default_factory=list)
|
|
239
|
+
input_mode: str = "stdin"
|
|
240
|
+
prompt_arg: str | None = None
|
|
241
|
+
timeout_seconds: int | None = None
|
|
242
|
+
env: Dict[str, str] = Field(default_factory=dict)
|
|
243
|
+
display_name: str | None = None
|
|
244
|
+
kind: str = "custom"
|
|
245
|
+
supports_mcp: bool = False
|
|
246
|
+
supports_diff_review: bool = False
|
|
247
|
+
default_profile: str = "default"
|
|
248
|
+
help_command: List[str] = Field(default_factory=list)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class CliAgentsIntegrationConfig(BaseModel):
|
|
252
|
+
enabled: bool = True
|
|
253
|
+
profiles: Dict[str, CliAgentProfileConfig] = Field(default_factory=dict)
|
|
254
|
+
agents: Dict[str, CustomCliAgentConfig] = Field(default_factory=dict)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class McpIntegrationConfig(BaseModel):
|
|
258
|
+
write_task_scope_to_config: bool = False
|
|
259
|
+
|
|
260
|
+
|
|
78
261
|
class IntegrationsConfig(BaseModel):
|
|
262
|
+
mcp: McpIntegrationConfig = Field(default_factory=McpIntegrationConfig)
|
|
79
263
|
agent_flow: AgentFlowIntegrationConfig = Field(default_factory=AgentFlowIntegrationConfig)
|
|
80
264
|
code_review_graph: CodeReviewGraphIntegrationConfig = Field(default_factory=CodeReviewGraphIntegrationConfig)
|
|
81
265
|
live_review: LiveReviewIntegrationConfig = Field(default_factory=LiveReviewIntegrationConfig)
|
|
266
|
+
cursor: CursorIntegrationConfig = Field(default_factory=CursorIntegrationConfig)
|
|
267
|
+
aider: AiderIntegrationConfig = Field(default_factory=AiderIntegrationConfig)
|
|
268
|
+
antigravity: AntigravityIntegrationConfig = Field(default_factory=AntigravityIntegrationConfig)
|
|
269
|
+
warp: WarpIntegrationConfig = Field(default_factory=WarpIntegrationConfig)
|
|
270
|
+
opencode: OpenCodeIntegrationConfig = Field(default_factory=OpenCodeIntegrationConfig)
|
|
271
|
+
cli_agents: CliAgentsIntegrationConfig = Field(default_factory=CliAgentsIntegrationConfig)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class KnowledgeConfig(BaseModel):
|
|
275
|
+
"""Ingested knowledge (Open Knowledge Format bundles + a project design.md) that gets
|
|
276
|
+
injected into planning/council/task prompts.
|
|
277
|
+
|
|
278
|
+
Sources live under ``<directory>/{okf,design}``. A design system is always selected
|
|
279
|
+
(``design_always``) because a coding agent should honor it on every UI task; OKF
|
|
280
|
+
knowledge is selected by goal keywords / document tags. The ``*_max_chars`` budgets
|
|
281
|
+
bound how much rides inline so a large knowledge base can't crowd out file context.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
enabled: bool = True
|
|
285
|
+
directory: str = ".devcouncil/knowledge"
|
|
286
|
+
design_always: bool = True
|
|
287
|
+
okf_max_chars: int = 3000
|
|
288
|
+
design_max_chars: int = 4000
|
|
82
289
|
|
|
83
290
|
|
|
84
291
|
class ProviderConfig(BaseModel):
|
|
@@ -97,42 +304,102 @@ class DevCouncilConfig(BaseModel):
|
|
|
97
304
|
commands: CommandsConfig = Field(default_factory=CommandsConfig)
|
|
98
305
|
gates: GatesConfig = Field(default_factory=GatesConfig)
|
|
99
306
|
execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
|
|
307
|
+
verification: VerificationConfig = Field(default_factory=VerificationConfig)
|
|
100
308
|
privacy: PrivacyConfig = Field(default_factory=PrivacyConfig)
|
|
101
309
|
integrations: IntegrationsConfig = Field(default_factory=IntegrationsConfig)
|
|
310
|
+
knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# Memoized parsed configs keyed by resolved config path. Each entry stores the
|
|
314
|
+
# file's stat signature (mtime_ns, size, inode) so a rewritten config.yaml — common in
|
|
315
|
+
# tests that mutate config mid-process, including delete+recreate — is re-read instead
|
|
316
|
+
# of served stale.
|
|
317
|
+
_CONFIG_CACHE: Dict[Path, Tuple[Tuple[int, int, int], DevCouncilConfig]] = {}
|
|
102
318
|
|
|
103
319
|
|
|
104
320
|
def load_config(project_root: Path = Path(".")) -> DevCouncilConfig:
|
|
105
321
|
"""Load and validate .devcouncil/config.yaml.
|
|
106
|
-
|
|
322
|
+
|
|
107
323
|
Returns DevCouncilConfig with defaults for any missing fields.
|
|
108
324
|
Raises FileNotFoundError if config doesn't exist.
|
|
325
|
+
|
|
326
|
+
Parsed results are memoized per resolved config path and invalidated when the
|
|
327
|
+
file's mtime/size changes, so repeated callers avoid redundant disk reads while
|
|
328
|
+
still picking up a rewritten config.
|
|
109
329
|
"""
|
|
110
|
-
import yaml
|
|
330
|
+
import yaml # type: ignore[import-untyped]
|
|
111
331
|
|
|
112
332
|
config_path = project_root / ".devcouncil" / "config.yaml"
|
|
113
|
-
|
|
333
|
+
try:
|
|
334
|
+
stat = config_path.stat()
|
|
335
|
+
except FileNotFoundError:
|
|
114
336
|
raise FileNotFoundError(f"Config not found at {config_path}. Run 'dev init' first.")
|
|
115
337
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
338
|
+
cache_key = config_path.resolve()
|
|
339
|
+
# Include the inode so a delete+recreate under the same path (new inode) is treated
|
|
340
|
+
# as changed even if the rewritten file lands with the same mtime and size.
|
|
341
|
+
signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
|
|
342
|
+
cached = _CONFIG_CACHE.get(cache_key)
|
|
343
|
+
if cached is not None and cached[0] == signature:
|
|
344
|
+
# The cached instance is shared across callers; treat it as read-only. Nothing
|
|
345
|
+
# in the codebase mutates a loaded DevCouncilConfig (settings are rewritten on
|
|
346
|
+
# disk via the raw-config helpers, then re-read), so we avoid a per-call copy.
|
|
347
|
+
return cached[1]
|
|
348
|
+
|
|
349
|
+
logger.debug("Loading config from %s", config_path)
|
|
350
|
+
with open(config_path, encoding="utf-8") as f:
|
|
351
|
+
try:
|
|
352
|
+
raw = yaml.safe_load(f) or {}
|
|
353
|
+
except yaml.YAMLError as exc:
|
|
354
|
+
logger.error("Invalid YAML in %s: %s", config_path, exc)
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"Invalid YAML in {config_path}: {exc}. Fix the syntax or re-run 'dev init'."
|
|
357
|
+
) from exc
|
|
358
|
+
|
|
359
|
+
config = DevCouncilConfig.model_validate(raw)
|
|
360
|
+
_CONFIG_CACHE[cache_key] = (signature, config)
|
|
361
|
+
logger.debug("Config loaded: provider=%s", config.models.provider)
|
|
362
|
+
return config
|
|
120
363
|
|
|
121
364
|
|
|
122
365
|
def provider_api_key_env_var(provider: str = "openrouter") -> str:
|
|
366
|
+
normalized = provider.strip().lower().replace("-", "").replace("_", "")
|
|
123
367
|
env_map = {
|
|
124
368
|
"openrouter": "OPENROUTER_API_KEY",
|
|
369
|
+
"vertexai": "VERTEXAI_ACCESS_TOKEN",
|
|
370
|
+
"doubleword": "DOUBLEWORD_API_KEY",
|
|
371
|
+
"ollama": "OLLAMA_API_KEY",
|
|
125
372
|
"openai": "OPENAI_API_KEY",
|
|
126
373
|
"anthropic": "ANTHROPIC_API_KEY",
|
|
127
374
|
}
|
|
128
|
-
return env_map.get(
|
|
375
|
+
return env_map.get(normalized, f"{normalized.upper()}_API_KEY")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _normalized_provider_name(provider: str) -> str:
|
|
379
|
+
return provider.strip().lower().replace("-", "").replace("_", "")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# Memoized parsed secrets keyed by resolved secrets path, with the same
|
|
383
|
+
# stat-signature (mtime_ns, size) invalidation as load_config.
|
|
384
|
+
_SECRETS_CACHE: Dict[Path, Tuple[Tuple[int, int, int], Dict[str, str]]] = {}
|
|
129
385
|
|
|
130
386
|
|
|
131
387
|
def load_local_secrets(project_root: Path = Path(".")) -> Dict[str, str]:
|
|
132
388
|
secrets_path = project_root / ".devcouncil" / "secrets.env"
|
|
133
|
-
|
|
389
|
+
try:
|
|
390
|
+
stat = secrets_path.stat()
|
|
391
|
+
except FileNotFoundError:
|
|
134
392
|
return {}
|
|
135
393
|
|
|
394
|
+
cache_key = secrets_path.resolve()
|
|
395
|
+
# Include the inode so a delete+recreate under the same path (new inode) is treated
|
|
396
|
+
# as changed even if the rewritten file lands with the same mtime and size.
|
|
397
|
+
signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
|
|
398
|
+
cached = _SECRETS_CACHE.get(cache_key)
|
|
399
|
+
if cached is not None and cached[0] == signature:
|
|
400
|
+
# Copy so callers can't mutate the cached mapping.
|
|
401
|
+
return dict(cached[1])
|
|
402
|
+
|
|
136
403
|
secrets: Dict[str, str] = {}
|
|
137
404
|
for line in secrets_path.read_text(encoding="utf-8").splitlines():
|
|
138
405
|
stripped = line.strip()
|
|
@@ -140,9 +407,66 @@ def load_local_secrets(project_root: Path = Path(".")) -> Dict[str, str]:
|
|
|
140
407
|
continue
|
|
141
408
|
key, value = stripped.split("=", 1)
|
|
142
409
|
secrets[key.strip()] = value.strip().strip('"').strip("'")
|
|
410
|
+
_SECRETS_CACHE[cache_key] = (signature, dict(secrets))
|
|
143
411
|
return secrets
|
|
144
412
|
|
|
145
413
|
|
|
414
|
+
def get_gcloud_access_token() -> str | None:
|
|
415
|
+
"""Fetch a fresh gcloud access token by shelling out to ``gcloud``.
|
|
416
|
+
|
|
417
|
+
This always spawns the subprocess (no caching) so callers that need a guaranteed
|
|
418
|
+
fresh token — e.g. the Vertex provider refreshing after a 401/403 — get one. For
|
|
419
|
+
the hot path (per-provider-construction key lookups) use
|
|
420
|
+
:func:`get_cached_gcloud_access_token`, which memoizes the result with a TTL.
|
|
421
|
+
"""
|
|
422
|
+
executable = shutil.which("gcloud")
|
|
423
|
+
if not executable:
|
|
424
|
+
return None
|
|
425
|
+
try:
|
|
426
|
+
token = subprocess.check_output(
|
|
427
|
+
[executable, "auth", "print-access-token"],
|
|
428
|
+
stderr=subprocess.STDOUT,
|
|
429
|
+
text=True,
|
|
430
|
+
encoding="utf-8",
|
|
431
|
+
errors="replace",
|
|
432
|
+
timeout=10,
|
|
433
|
+
).strip()
|
|
434
|
+
except Exception:
|
|
435
|
+
return None
|
|
436
|
+
return token or None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# Cached gcloud access token + monotonic expiry. gcloud tokens last ~60 min, so the
|
|
440
|
+
# hot path (a fresh provider per role/run calling get_api_key) reuses a fetched token
|
|
441
|
+
# for a conservative window instead of spawning ``gcloud auth print-access-token`` on
|
|
442
|
+
# every lookup. A single gcloud identity is assumed, so no cache key is needed. A
|
|
443
|
+
# failed fetch (None) is never cached.
|
|
444
|
+
_GCLOUD_TOKEN_TTL_SECONDS = 50 * 60
|
|
445
|
+
_gcloud_token_cache: Optional[Tuple[str, float]] = None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def get_cached_gcloud_access_token() -> str | None:
|
|
449
|
+
"""Return a gcloud access token, reusing a recent one within the TTL window.
|
|
450
|
+
|
|
451
|
+
Falls back to :func:`get_gcloud_access_token` on a cache miss/expiry. ``gcloud``
|
|
452
|
+
absence is checked first so an environment without gcloud short-circuits to None
|
|
453
|
+
without ever consulting the cache (preserving the uncached error/None behavior).
|
|
454
|
+
"""
|
|
455
|
+
global _gcloud_token_cache
|
|
456
|
+
|
|
457
|
+
if shutil.which("gcloud") is None:
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
cached = _gcloud_token_cache
|
|
461
|
+
if cached is not None and time.monotonic() < cached[1]:
|
|
462
|
+
return cached[0]
|
|
463
|
+
|
|
464
|
+
token = get_gcloud_access_token()
|
|
465
|
+
if token:
|
|
466
|
+
_gcloud_token_cache = (token, time.monotonic() + _GCLOUD_TOKEN_TTL_SECONDS)
|
|
467
|
+
return token
|
|
468
|
+
|
|
469
|
+
|
|
146
470
|
def get_api_key(provider: str = "openrouter", project_root: Path = Path(".")) -> str:
|
|
147
471
|
"""Retrieve the API key for the configured provider from environment.
|
|
148
472
|
|
|
@@ -150,9 +474,21 @@ def get_api_key(provider: str = "openrouter", project_root: Path = Path(".")) ->
|
|
|
150
474
|
"""
|
|
151
475
|
env_var = provider_api_key_env_var(provider)
|
|
152
476
|
key = os.environ.get(env_var) or load_local_secrets(project_root).get(env_var)
|
|
477
|
+
if not key and _normalized_provider_name(provider) == "vertexai":
|
|
478
|
+
key = get_cached_gcloud_access_token()
|
|
479
|
+
if not key and _normalized_provider_name(provider) == "ollama":
|
|
480
|
+
# Ollama is a local server and needs no API key; an explicitly-set
|
|
481
|
+
# OLLAMA_API_KEY still flows through above if present.
|
|
482
|
+
return ""
|
|
153
483
|
if not key:
|
|
484
|
+
logger.warning("API key not found for provider %s (env var %s)", provider, env_var)
|
|
485
|
+
extra = (
|
|
486
|
+
" You can also authenticate with 'gcloud auth login' for vertexai."
|
|
487
|
+
if _normalized_provider_name(provider) == "vertexai"
|
|
488
|
+
else ""
|
|
489
|
+
)
|
|
154
490
|
raise ValueError(
|
|
155
491
|
f"API key not found. Set {env_var} in your environment or run 'dev setup'. "
|
|
156
|
-
f"Provider: {provider}"
|
|
492
|
+
f"Provider: {provider}.{extra}"
|
|
157
493
|
)
|
|
158
494
|
return key
|
|
@@ -18,9 +18,13 @@ class Orchestrator:
|
|
|
18
18
|
self.project_root = project_root
|
|
19
19
|
self.persist_state = persist_state
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
# Cache the Database handle once: get_db() rebuilds the SQLAlchemy engine
|
|
22
|
+
# and runs a schema check on every call, and transition_to() is invoked
|
|
23
|
+
# several times per run. The handle is reusable, so build it here and
|
|
24
|
+
# reuse it everywhere in this orchestrator instance.
|
|
25
|
+
self.db = get_db(self.project_root)
|
|
26
|
+
if self.db:
|
|
27
|
+
with self.db.get_session() as session:
|
|
24
28
|
repo = StateRepository(session)
|
|
25
29
|
state = repo.get_state()
|
|
26
30
|
if state:
|
|
@@ -45,6 +49,7 @@ class Orchestrator:
|
|
|
45
49
|
goal=goal
|
|
46
50
|
)
|
|
47
51
|
self.current_run.initialize()
|
|
52
|
+
logger.info("Run started: run_id=%s goal=%r", run_id, goal)
|
|
48
53
|
TraceLogger(self.project_root).log_event(
|
|
49
54
|
"planning_started",
|
|
50
55
|
{"goal": goal},
|
|
@@ -60,9 +65,8 @@ class Orchestrator:
|
|
|
60
65
|
old_phase = self.state_machine.phase
|
|
61
66
|
self.state_machine.transition(target_phase)
|
|
62
67
|
|
|
63
|
-
db
|
|
64
|
-
|
|
65
|
-
with db.get_session() as session:
|
|
68
|
+
if self.db and self.persist_state:
|
|
69
|
+
with self.db.get_session() as session:
|
|
66
70
|
repo = StateRepository(session)
|
|
67
71
|
repo.save_state(
|
|
68
72
|
self.state_machine.phase.value,
|
|
@@ -9,9 +9,12 @@ States (from §12):
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import logging
|
|
12
13
|
from enum import Enum
|
|
13
14
|
from typing import Dict, List, Set
|
|
14
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
class ProjectPhase(str, Enum):
|
|
@@ -99,6 +102,7 @@ class StateMachine:
|
|
|
99
102
|
Raises InvalidTransitionError if the transition is not allowed.
|
|
100
103
|
"""
|
|
101
104
|
if not self.can_transition(target):
|
|
105
|
+
logger.error("Invalid phase transition: %s -> %s", self._phase.value, target.value)
|
|
102
106
|
raise InvalidTransitionError(self._phase, target)
|
|
103
107
|
self._phase = target
|
|
104
108
|
self._history.append(target)
|
|
@@ -18,7 +18,7 @@ from typing import Any, Dict, List, Set, Tuple
|
|
|
18
18
|
from devcouncil.domain.requirement import Requirement, AcceptanceCriterion
|
|
19
19
|
from devcouncil.domain.task import Task
|
|
20
20
|
from devcouncil.domain.assumption import Assumption
|
|
21
|
-
from devcouncil.domain.evidence import CommandResult, DiffEvidence, TestEvidence
|
|
21
|
+
from devcouncil.domain.evidence import CommandResult, DiffCoverageEvidence, DiffEvidence, TestEvidence
|
|
22
22
|
from devcouncil.domain.gap import Gap
|
|
23
23
|
from devcouncil.domain.critique import CritiqueFinding
|
|
24
24
|
|
|
@@ -35,6 +35,7 @@ class ArtifactGraph:
|
|
|
35
35
|
test_evidence: List[TestEvidence] = field(default_factory=list)
|
|
36
36
|
diff_evidence: List[DiffEvidence] = field(default_factory=list)
|
|
37
37
|
command_results: List[CommandResult] = field(default_factory=list)
|
|
38
|
+
diff_coverage_evidence: List[DiffCoverageEvidence] = field(default_factory=list)
|
|
38
39
|
|
|
39
40
|
# --- Mutation ---
|
|
40
41
|
|
|
@@ -62,6 +63,17 @@ class ArtifactGraph:
|
|
|
62
63
|
def add_command_result(self, cr: CommandResult) -> None:
|
|
63
64
|
self.command_results.append(cr)
|
|
64
65
|
|
|
66
|
+
def add_diff_coverage_evidence(self, ev: DiffCoverageEvidence) -> None:
|
|
67
|
+
self.diff_coverage_evidence.append(ev)
|
|
68
|
+
|
|
69
|
+
def diff_coverage_findings(self) -> List[DiffCoverageEvidence]:
|
|
70
|
+
"""Measured diff-coverage runs where the changed lines were NOT fully exercised
|
|
71
|
+
— i.e. a green suite that did not actually run the new code."""
|
|
72
|
+
return [
|
|
73
|
+
ev for ev in self.diff_coverage_evidence
|
|
74
|
+
if ev.measured and ev.changed_lines and ev.covered_lines < ev.changed_lines
|
|
75
|
+
]
|
|
76
|
+
|
|
65
77
|
# --- Coverage Queries ---
|
|
66
78
|
|
|
67
79
|
def requirements_without_tasks(self) -> List[Requirement]:
|
|
@@ -80,10 +92,16 @@ class ArtifactGraph:
|
|
|
80
92
|
return [r for r in self.requirements.values() if not r.acceptance_criteria]
|
|
81
93
|
|
|
82
94
|
def acceptance_criteria_without_evidence(self) -> List[Tuple[str, AcceptanceCriterion]]:
|
|
83
|
-
"""AC IDs that have no test evidence mapped to them.
|
|
95
|
+
"""AC IDs that have no *passing* test evidence mapped to them.
|
|
96
|
+
|
|
97
|
+
Only ``passed`` evidence counts: a failed or not-run check is not proof, so
|
|
98
|
+
it must not remove a criterion from the unproven list (which would feed a
|
|
99
|
+
falsely-green coverage summary to ``dev status`` and the MCP surface).
|
|
100
|
+
"""
|
|
84
101
|
evidenced_ac_ids: Set[str] = set()
|
|
85
102
|
for ev in self.test_evidence:
|
|
86
|
-
|
|
103
|
+
if getattr(ev, "status", "passed") == "passed":
|
|
104
|
+
evidenced_ac_ids.add(ev.acceptance_criterion_id)
|
|
87
105
|
|
|
88
106
|
results: List[Tuple[str, AcceptanceCriterion]] = []
|
|
89
107
|
for req in self.requirements.values():
|
|
@@ -126,6 +144,13 @@ class ArtifactGraph:
|
|
|
126
144
|
|
|
127
145
|
def coverage_summary(self) -> Dict[str, Any]:
|
|
128
146
|
"""Produce a coverage summary for reporting."""
|
|
147
|
+
# Single pass over open findings; the "high"-filtered count reuses the same
|
|
148
|
+
# severity_order/rank predicate as open_findings("high") (rank >= 2).
|
|
149
|
+
all_open = self.open_findings()
|
|
150
|
+
severity_order = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
151
|
+
high_critical_open = sum(
|
|
152
|
+
1 for f in all_open if severity_order.get(f.severity, 0) >= severity_order["high"]
|
|
153
|
+
)
|
|
129
154
|
return {
|
|
130
155
|
"total_requirements": len(self.requirements),
|
|
131
156
|
"requirements_without_tasks": len(self.requirements_without_tasks()),
|
|
@@ -137,7 +162,9 @@ class ArtifactGraph:
|
|
|
137
162
|
"ac_without_evidence": len(self.acceptance_criteria_without_evidence()),
|
|
138
163
|
"total_gaps": len(self.gaps),
|
|
139
164
|
"blocking_gaps": len(self.blocking_gaps()),
|
|
140
|
-
"
|
|
141
|
-
"
|
|
165
|
+
"diff_coverage_runs": len(self.diff_coverage_evidence),
|
|
166
|
+
"unexercised_diff_findings": len(self.diff_coverage_findings()),
|
|
167
|
+
"open_findings": len(all_open),
|
|
168
|
+
"high_critical_open_findings": high_critical_open,
|
|
142
169
|
"unconfirmed_high_assumptions": len(self.unconfirmed_high_impact_assumptions()),
|
|
143
170
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Packaged visual assets for DevCouncil."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="100%" height="100%">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" stop-color="#1a0000" />
|
|
5
|
+
<stop offset="100%" stop-color="#000000" />
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="redGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
8
|
+
<stop offset="0%" stop-color="#ff3333" />
|
|
9
|
+
<stop offset="100%" stop-color="#990000" />
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
|
12
|
+
<feGaussianBlur stdDeviation="10" result="blur" />
|
|
13
|
+
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
|
14
|
+
</filter>
|
|
15
|
+
</defs>
|
|
16
|
+
|
|
17
|
+
<rect width="500" height="500" fill="transparent" />
|
|
18
|
+
|
|
19
|
+
<g transform="translate(250, 250)">
|
|
20
|
+
<!-- Outer wireframe hexagon representing the bounds of the orchestrator -->
|
|
21
|
+
<polygon points="0,-180 155.88,-90 155.88,90 0,180 -155.88,90 -155.88,-90"
|
|
22
|
+
fill="none" stroke="#330000" stroke-width="8" stroke-linejoin="round" />
|
|
23
|
+
|
|
24
|
+
<!-- Inner geometric structure - Top Face -->
|
|
25
|
+
<polygon points="0,-180 155.88,-90 0,0 -155.88,-90" fill="#0d0000" stroke="#ff0000" stroke-width="2" />
|
|
26
|
+
|
|
27
|
+
<!-- Inner geometric structure - Right Face -->
|
|
28
|
+
<polygon points="0,0 155.88,-90 155.88,90 0,180" fill="#000000" stroke="#cc0000" stroke-width="2" />
|
|
29
|
+
|
|
30
|
+
<!-- Inner geometric structure - Left Face -->
|
|
31
|
+
<polygon points="0,0 0,180 -155.88,90 -155.88,-90" fill="#1a0000" stroke="#ff3333" stroke-width="2" />
|
|
32
|
+
|
|
33
|
+
<!-- The glowing core node (3D Isometric) -->
|
|
34
|
+
<!-- Top core face -->
|
|
35
|
+
<polygon points="0,-80 69.28,-40 0,0 -69.28,-40" fill="#ff4d4d" />
|
|
36
|
+
|
|
37
|
+
<!-- Right core face -->
|
|
38
|
+
<polygon points="0,0 69.28,-40 69.28,40 0,80" fill="#b30000" />
|
|
39
|
+
|
|
40
|
+
<!-- Left core face -->
|
|
41
|
+
<polygon points="0,0 0,80 -69.28,40 -69.28,-40" fill="#800000" />
|
|
42
|
+
|
|
43
|
+
<!-- Power lines radiating from the core -->
|
|
44
|
+
<line x1="0" y1="-80" x2="0" y2="-180" stroke="#ff0000" stroke-width="4" filter="url(#glow)" />
|
|
45
|
+
<line x1="69.28" y1="40" x2="155.88" y2="90" stroke="#ff0000" stroke-width="4" filter="url(#glow)" />
|
|
46
|
+
<line x1="-69.28" y1="40" x2="-155.88" y2="90" stroke="#ff0000" stroke-width="4" filter="url(#glow)" />
|
|
47
|
+
|
|
48
|
+
<line x1="0" y1="80" x2="0" y2="180" stroke="#ff0000" stroke-width="2" stroke-dasharray="5,5" />
|
|
49
|
+
<line x1="-69.28" y1="-40" x2="-155.88" y2="-90" stroke="#ff0000" stroke-width="2" stroke-dasharray="5,5" />
|
|
50
|
+
<line x1="69.28" y1="-40" x2="155.88" y2="-90" stroke="#ff0000" stroke-width="2" stroke-dasharray="5,5" />
|
|
51
|
+
|
|
52
|
+
<!-- Terminal connection dots -->
|
|
53
|
+
<circle cx="0" cy="-180" r="8" fill="#ff3333" filter="url(#glow)" />
|
|
54
|
+
<circle cx="155.88" cy="90" r="8" fill="#ff3333" filter="url(#glow)" />
|
|
55
|
+
<circle cx="-155.88" cy="90" r="8" fill="#ff3333" filter="url(#glow)" />
|
|
56
|
+
<circle cx="0" cy="180" r="5" fill="#800000" />
|
|
57
|
+
<circle cx="-155.88" cy="-90" r="5" fill="#800000" />
|
|
58
|
+
<circle cx="155.88" cy="-90" r="5" fill="#800000" />
|
|
59
|
+
</g>
|
|
60
|
+
</svg>
|
|
Binary file
|