devtorch-core 3.0.1__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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM-powered consolidation synthesizer.
|
|
3
|
+
|
|
4
|
+
Uses the same pattern as theta_synthesis.py: builds a prompt from
|
|
5
|
+
ReasoningEntries + DivergenceSignals, sends to claude-haiku, returns
|
|
6
|
+
a ConsolidationDraft. Gated by DEVTORCH_DISABLE env var.
|
|
7
|
+
Never raises — errors return a skip result.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import os
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclasses.dataclass
|
|
17
|
+
class ConsolidationDraft:
|
|
18
|
+
decision: str
|
|
19
|
+
decision_trace: str
|
|
20
|
+
model: str
|
|
21
|
+
tokens_used: int
|
|
22
|
+
skipped: bool = False
|
|
23
|
+
skip_reason: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def synthesise_consolidation(
|
|
27
|
+
concept: str,
|
|
28
|
+
reasoning_entries: list,
|
|
29
|
+
divergence_signals: list,
|
|
30
|
+
model: str = "claude-haiku-4-5-20251001",
|
|
31
|
+
) -> ConsolidationDraft:
|
|
32
|
+
"""Run LLM synthesis over reasoning entries + divergence signals."""
|
|
33
|
+
if os.environ.get("DEVTORCH_DISABLE"):
|
|
34
|
+
return ConsolidationDraft(decision="", decision_trace="", model=model, tokens_used=0,
|
|
35
|
+
skipped=True, skip_reason="DEVTORCH_DISABLE set")
|
|
36
|
+
try:
|
|
37
|
+
import anthropic
|
|
38
|
+
except ImportError:
|
|
39
|
+
return ConsolidationDraft(decision="", decision_trace="", model=model, tokens_used=0,
|
|
40
|
+
skipped=True, skip_reason="anthropic SDK not installed")
|
|
41
|
+
|
|
42
|
+
prompt = _build_prompt(concept, reasoning_entries, divergence_signals)
|
|
43
|
+
try:
|
|
44
|
+
client = anthropic.Anthropic()
|
|
45
|
+
response = client.messages.create(
|
|
46
|
+
model=model,
|
|
47
|
+
max_tokens=512,
|
|
48
|
+
messages=[{"role": "user", "content": prompt}],
|
|
49
|
+
)
|
|
50
|
+
text = response.content[0].text if response.content else ""
|
|
51
|
+
tokens = response.usage.input_tokens + response.usage.output_tokens
|
|
52
|
+
decision, trace = _parse_response(text)
|
|
53
|
+
return ConsolidationDraft(decision=decision, decision_trace=trace,
|
|
54
|
+
model=model, tokens_used=tokens)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
return ConsolidationDraft(decision="", decision_trace="", model=model, tokens_used=0,
|
|
57
|
+
skipped=True, skip_reason=str(exc))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_prompt(concept: str, reasoning_entries: list, divergence_signals: list) -> str:
|
|
61
|
+
entry_lines = "\n".join(
|
|
62
|
+
f" [{i+1}] agent={e.agent_id} conf={e.confidence:.2f}: {e.reasoning_text[:200]}"
|
|
63
|
+
for i, e in enumerate(reasoning_entries[:10])
|
|
64
|
+
)
|
|
65
|
+
signal_lines = "\n".join(
|
|
66
|
+
f" [{s.signal_type}/{s.severity}] {s.description}"
|
|
67
|
+
for s in divergence_signals[:5]
|
|
68
|
+
)
|
|
69
|
+
return (
|
|
70
|
+
f"You are a governance analyst. Agents have diverged on concept: '{concept}'.\n\n"
|
|
71
|
+
f"Agent reasoning:\n{entry_lines or ' (none)'}\n\n"
|
|
72
|
+
f"Divergence signals:\n{signal_lines or ' (none)'}\n\n"
|
|
73
|
+
"Write two sections:\n"
|
|
74
|
+
"DECISION: A 1-2 sentence consolidated position that resolves the divergence.\n"
|
|
75
|
+
"TRACE: A 1-2 sentence explanation of WHY this consolidation was reached.\n\n"
|
|
76
|
+
"Format exactly:\nDECISION: <text>\nTRACE: <text>"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_response(text: str) -> tuple[str, str]:
|
|
81
|
+
decision, trace = "", ""
|
|
82
|
+
for line in text.splitlines():
|
|
83
|
+
if line.startswith("DECISION:"):
|
|
84
|
+
decision = line[len("DECISION:"):].strip()
|
|
85
|
+
elif line.startswith("TRACE:"):
|
|
86
|
+
trace = line[len("TRACE:"):].strip()
|
|
87
|
+
return decision or text[:200], trace or "LLM synthesis"
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import datetime as _dt
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from devtorch_core.divergence.detector import DivergenceSignal
|
|
11
|
+
|
|
12
|
+
_CONSOLIDATIONS_DIR = "consolidations"
|
|
13
|
+
|
|
14
|
+
_SEVERITY_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass
|
|
18
|
+
class ConsolidationRecord:
|
|
19
|
+
"""
|
|
20
|
+
Durable record of a consolidation event.
|
|
21
|
+
|
|
22
|
+
decision_trace is DevTorch's own reasoning — WHY it chose to block,
|
|
23
|
+
escalate to human, or auto-resolve.
|
|
24
|
+
"""
|
|
25
|
+
record_id: str
|
|
26
|
+
concept: str
|
|
27
|
+
signals: List[dict] # serialised DivergenceSignal dicts
|
|
28
|
+
state: str # drafting | pending_human | auto_resolved | resolved | blocked
|
|
29
|
+
decision: str # consolidated position text
|
|
30
|
+
decision_trace: str # DevTorch's reasoning for its action
|
|
31
|
+
agents_involved: List[str]
|
|
32
|
+
human_comments: List[dict]
|
|
33
|
+
created_at: str
|
|
34
|
+
resolved_at: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return dataclasses.asdict(self)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, d: dict) -> "ConsolidationRecord":
|
|
41
|
+
known = {f.name for f in dataclasses.fields(cls)}
|
|
42
|
+
return cls(**{k: v for k, v in d.items() if k in known})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConsolidationWorkflow:
|
|
46
|
+
"""
|
|
47
|
+
State machine for agent reasoning consolidation.
|
|
48
|
+
|
|
49
|
+
Transitions:
|
|
50
|
+
drafting → pending_human (HIGH or MEDIUM severity divergence)
|
|
51
|
+
drafting → auto_resolved (LOW severity only)
|
|
52
|
+
drafting → blocked (signal type E: consensus contradiction)
|
|
53
|
+
pending_human → resolved (human provides decision via add_human_comment)
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, repo) -> None:
|
|
57
|
+
self._repo = repo
|
|
58
|
+
self._gcc_dir = repo.gcc_dir
|
|
59
|
+
self._dir = self._gcc_dir / _CONSOLIDATIONS_DIR
|
|
60
|
+
self._dir.mkdir(exist_ok=True)
|
|
61
|
+
|
|
62
|
+
def start(
|
|
63
|
+
self,
|
|
64
|
+
concept: str,
|
|
65
|
+
signals: List[DivergenceSignal],
|
|
66
|
+
) -> ConsolidationRecord:
|
|
67
|
+
"""Create a ConsolidationRecord and determine initial state from signals."""
|
|
68
|
+
record_id = str(uuid.uuid4())
|
|
69
|
+
now = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
|
|
70
|
+
|
|
71
|
+
all_agents = list({aid for s in signals for aid in s.agent_ids})
|
|
72
|
+
max_severity = self._max_severity(signals)
|
|
73
|
+
|
|
74
|
+
# E-type signals always block
|
|
75
|
+
e_signals = [s for s in signals if s.signal_type == "E"]
|
|
76
|
+
if e_signals:
|
|
77
|
+
state = "blocked"
|
|
78
|
+
decision = ""
|
|
79
|
+
trace = (
|
|
80
|
+
f"Blocked: {len(e_signals)} consensus contradiction(s) detected on '{concept}'. "
|
|
81
|
+
"Human review required before proceeding."
|
|
82
|
+
)
|
|
83
|
+
elif max_severity == "LOW":
|
|
84
|
+
state = "auto_resolved"
|
|
85
|
+
decision = self._auto_decision(concept, signals)
|
|
86
|
+
trace = (
|
|
87
|
+
f"Auto-resolved: all signals LOW severity on '{concept}'. "
|
|
88
|
+
f"Signals: {', '.join(s.signal_type for s in signals)}. "
|
|
89
|
+
"No human intervention required."
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
state = "pending_human"
|
|
93
|
+
decision = ""
|
|
94
|
+
trace = (
|
|
95
|
+
f"Escalated to human: {max_severity} severity signal(s) detected on '{concept}'. "
|
|
96
|
+
f"Signal types: {', '.join(s.signal_type + '(' + s.severity + ')' for s in signals)}. "
|
|
97
|
+
"Awaiting human comment or resolution."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
record = ConsolidationRecord(
|
|
101
|
+
record_id=record_id,
|
|
102
|
+
concept=concept,
|
|
103
|
+
signals=[dataclasses.asdict(s) for s in signals],
|
|
104
|
+
state=state,
|
|
105
|
+
decision=decision,
|
|
106
|
+
decision_trace=trace,
|
|
107
|
+
agents_involved=all_agents,
|
|
108
|
+
human_comments=[],
|
|
109
|
+
created_at=now,
|
|
110
|
+
resolved_at=now if state == "auto_resolved" else None,
|
|
111
|
+
)
|
|
112
|
+
self._save(record)
|
|
113
|
+
return record
|
|
114
|
+
|
|
115
|
+
def add_human_comment(
|
|
116
|
+
self,
|
|
117
|
+
record_id: str,
|
|
118
|
+
author: str,
|
|
119
|
+
comment: str,
|
|
120
|
+
decision: Optional[str] = None,
|
|
121
|
+
) -> ConsolidationRecord:
|
|
122
|
+
"""Add a human comment and optionally resolve the record."""
|
|
123
|
+
record = self.get(record_id)
|
|
124
|
+
if record is None:
|
|
125
|
+
raise ValueError(f"ConsolidationRecord {record_id!r} not found")
|
|
126
|
+
|
|
127
|
+
now = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
|
|
128
|
+
record.human_comments.append({"author": author, "comment": comment, "timestamp": now})
|
|
129
|
+
|
|
130
|
+
if decision is not None:
|
|
131
|
+
record.decision = decision
|
|
132
|
+
record.state = "resolved"
|
|
133
|
+
record.resolved_at = now
|
|
134
|
+
record.decision_trace += f"\nResolved by {author} at {now[:19]}: {decision}"
|
|
135
|
+
|
|
136
|
+
self._save(record)
|
|
137
|
+
return record
|
|
138
|
+
|
|
139
|
+
def get(self, record_id: str) -> Optional[ConsolidationRecord]:
|
|
140
|
+
path = self._dir / f"{record_id}.json"
|
|
141
|
+
if not path.exists():
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
return ConsolidationRecord.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
|
145
|
+
except (json.JSONDecodeError, TypeError):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def list_for_concept(self, concept: str) -> List[ConsolidationRecord]:
|
|
149
|
+
results = []
|
|
150
|
+
for f in self._dir.glob("*.json"):
|
|
151
|
+
try:
|
|
152
|
+
d = json.loads(f.read_text(encoding="utf-8"))
|
|
153
|
+
if d.get("concept") == concept:
|
|
154
|
+
results.append(ConsolidationRecord.from_dict(d))
|
|
155
|
+
except (json.JSONDecodeError, TypeError):
|
|
156
|
+
continue
|
|
157
|
+
return sorted(results, key=lambda r: r.created_at, reverse=True)
|
|
158
|
+
|
|
159
|
+
def _save(self, record: ConsolidationRecord) -> None:
|
|
160
|
+
path = self._dir / f"{record.record_id}.json"
|
|
161
|
+
path.write_text(json.dumps(record.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _max_severity(signals: List[DivergenceSignal]) -> str:
|
|
165
|
+
if not signals:
|
|
166
|
+
return "LOW"
|
|
167
|
+
return min(signals, key=lambda s: _SEVERITY_ORDER.get(s.severity, 99)).severity
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _auto_decision(concept: str, signals: List[DivergenceSignal]) -> str:
|
|
171
|
+
return (
|
|
172
|
+
f"No high-severity divergence detected on '{concept}'. "
|
|
173
|
+
"Agents may proceed with existing sensitivity signals. "
|
|
174
|
+
f"Low-severity signals noted: {', '.join(s.description[:80] for s in signals)}."
|
|
175
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""DevTorch background daemon (S26).
|
|
2
|
+
|
|
3
|
+
Provides a long-running filesystem watcher that emits `fs_change` events to
|
|
4
|
+
`.GCC/events.log.jsonl` without requiring IDE hooks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from devtorch_core.daemon.supervisor import (
|
|
8
|
+
PID_FILE_NAME,
|
|
9
|
+
LOG_FILE_NAME,
|
|
10
|
+
start_daemon,
|
|
11
|
+
stop_daemon,
|
|
12
|
+
daemon_status,
|
|
13
|
+
daemon_logs,
|
|
14
|
+
_run_daemon,
|
|
15
|
+
)
|
|
16
|
+
from devtorch_core.daemon.watcher import ProjectWatcher
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"PID_FILE_NAME",
|
|
20
|
+
"LOG_FILE_NAME",
|
|
21
|
+
"start_daemon",
|
|
22
|
+
"stop_daemon",
|
|
23
|
+
"daemon_status",
|
|
24
|
+
"daemon_logs",
|
|
25
|
+
"_run_daemon",
|
|
26
|
+
"ProjectWatcher",
|
|
27
|
+
]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background daemon supervisor for the DevTorch CLI (S26).
|
|
3
|
+
|
|
4
|
+
The daemon runs a filesystem watcher in the background and optionally the
|
|
5
|
+
unified DevTorch server. It writes its PID to `.GCC/devtorch_daemon.pid` and
|
|
6
|
+
logs to `.GCC/devtorch_daemon.log`.
|
|
7
|
+
|
|
8
|
+
CLI commands:
|
|
9
|
+
devtorch daemon start # idempotent
|
|
10
|
+
devtorch daemon stop
|
|
11
|
+
devtorch daemon status
|
|
12
|
+
devtorch daemon logs
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import signal
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Callable, Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
PID_FILE_NAME = "devtorch_daemon.pid"
|
|
27
|
+
LOG_FILE_NAME = "devtorch_daemon.log"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _gcc_dir(project_root: Path) -> Path:
|
|
31
|
+
"""Resolve the .GCC directory for a project root, ensuring it exists."""
|
|
32
|
+
from devtorch_core import GCCRepository
|
|
33
|
+
|
|
34
|
+
repo = GCCRepository.at(project_root)
|
|
35
|
+
if not repo.is_initialized():
|
|
36
|
+
raise RuntimeError(f"No initialized .GCC/ found at {project_root}")
|
|
37
|
+
return repo.gcc_dir
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_pid(gcc_dir: Path) -> Optional[int]:
|
|
41
|
+
"""Read the daemon PID from the PID file, if present and valid."""
|
|
42
|
+
pid_path = gcc_dir / PID_FILE_NAME
|
|
43
|
+
if not pid_path.exists():
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return int(pid_path.read_text(encoding="utf-8").strip())
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_alive(pid: int) -> bool:
|
|
52
|
+
"""Check whether a process with the given PID is still running."""
|
|
53
|
+
try:
|
|
54
|
+
os.kill(pid, 0)
|
|
55
|
+
return True
|
|
56
|
+
except (OSError, ProcessLookupError):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _wait_for_pid(
|
|
61
|
+
gcc_dir: Path,
|
|
62
|
+
timeout: float = 2.0,
|
|
63
|
+
interval: float = 0.1,
|
|
64
|
+
) -> Optional[int]:
|
|
65
|
+
"""Wait for a daemon PID file to appear and refer to a live process."""
|
|
66
|
+
deadline = time.time() + timeout
|
|
67
|
+
while time.time() < deadline:
|
|
68
|
+
pid = _read_pid(gcc_dir)
|
|
69
|
+
if pid is not None and _is_alive(pid):
|
|
70
|
+
return pid
|
|
71
|
+
time.sleep(interval)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# daemon process entry point
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run_daemon(
|
|
81
|
+
project_root: Optional[str] = None,
|
|
82
|
+
run_server: bool = False,
|
|
83
|
+
host: str = "127.0.0.1",
|
|
84
|
+
port: int = 8765,
|
|
85
|
+
stop_event: Optional[threading.Event] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Entry point for the daemon process.
|
|
88
|
+
|
|
89
|
+
This function is invoked by the subprocess spawned in `start_daemon`.
|
|
90
|
+
It writes the PID file, starts the project watcher, and optionally runs the
|
|
91
|
+
unified server. The process keeps running until it receives SIGTERM/SIGINT.
|
|
92
|
+
"""
|
|
93
|
+
if project_root is None:
|
|
94
|
+
project_root = os.environ.get("DEVTORCH_DAEMON_ROOT", str(Path.cwd()))
|
|
95
|
+
root = Path(project_root).resolve()
|
|
96
|
+
|
|
97
|
+
gcc_dir = _gcc_dir(root)
|
|
98
|
+
pid_path = gcc_dir / PID_FILE_NAME
|
|
99
|
+
pid_path.write_text(str(os.getpid()), encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
if stop_event is None:
|
|
102
|
+
stop_event = threading.Event()
|
|
103
|
+
|
|
104
|
+
def _handle_signal(signum: int, frame: Any) -> None:
|
|
105
|
+
stop_event.set()
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
109
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
110
|
+
except Exception:
|
|
111
|
+
# May fail in some test environments; continue without signal handling.
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
from devtorch_core.daemon.watcher import ProjectWatcher
|
|
116
|
+
|
|
117
|
+
watcher = ProjectWatcher(root)
|
|
118
|
+
watcher.start()
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
print(f"Failed to start watcher: {exc}", file=sys.stderr)
|
|
121
|
+
pid_path.unlink(missing_ok=True)
|
|
122
|
+
raise
|
|
123
|
+
|
|
124
|
+
server_thread: Optional[threading.Thread] = None
|
|
125
|
+
if run_server:
|
|
126
|
+
try:
|
|
127
|
+
from devtorch_core.serve import serve_all
|
|
128
|
+
|
|
129
|
+
def _serve() -> None:
|
|
130
|
+
try:
|
|
131
|
+
serve_all(host=host, port=port, gcc_path=str(root))
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
print(f"Unified server exited: {exc}", file=sys.stderr)
|
|
134
|
+
|
|
135
|
+
server_thread = threading.Thread(target=_serve, daemon=True)
|
|
136
|
+
server_thread.start()
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
print(f"Failed to start unified server: {exc}", file=sys.stderr)
|
|
139
|
+
|
|
140
|
+
print(f"DevTorch daemon started (PID {os.getpid()}) watching {root}")
|
|
141
|
+
sys.stdout.flush()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
while not stop_event.is_set():
|
|
145
|
+
stop_event.wait(1.0)
|
|
146
|
+
finally:
|
|
147
|
+
watcher.stop()
|
|
148
|
+
print(f"DevTorch daemon stopped (PID {os.getpid()})")
|
|
149
|
+
sys.stdout.flush()
|
|
150
|
+
pid_path.unlink(missing_ok=True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# process spawning
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_daemon_command(project_root: Path, run_server: bool, host: str, port: int) -> list[str]:
|
|
159
|
+
"""Build the command used to spawn the daemon subprocess."""
|
|
160
|
+
code = (
|
|
161
|
+
"from devtorch_core.daemon.supervisor import _run_daemon; "
|
|
162
|
+
f"_run_daemon(project_root={str(project_root)!r}, run_server={run_server!r}, "
|
|
163
|
+
f"host={host!r}, port={port!r})"
|
|
164
|
+
)
|
|
165
|
+
return [sys.executable, "-c", code]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# public control functions
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def start_daemon(
|
|
174
|
+
project_root: Path,
|
|
175
|
+
run_server: bool = False,
|
|
176
|
+
host: str = "127.0.0.1",
|
|
177
|
+
port: int = 8765,
|
|
178
|
+
popen_factory: Optional[Callable[..., Any]] = None,
|
|
179
|
+
) -> tuple[bool, str]:
|
|
180
|
+
"""Start the daemon if it is not already running.
|
|
181
|
+
|
|
182
|
+
Returns (success, message). If the daemon is already running, reports
|
|
183
|
+
the existing PID without starting a new process (idempotent).
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
gcc_dir = _gcc_dir(project_root)
|
|
187
|
+
except RuntimeError as exc:
|
|
188
|
+
return False, str(exc)
|
|
189
|
+
|
|
190
|
+
pid = _read_pid(gcc_dir)
|
|
191
|
+
if pid is not None and _is_alive(pid):
|
|
192
|
+
return True, f"DevTorch daemon already running (PID {pid})"
|
|
193
|
+
|
|
194
|
+
log_path = gcc_dir / LOG_FILE_NAME
|
|
195
|
+
cmd = _build_daemon_command(project_root, run_server, host, port)
|
|
196
|
+
|
|
197
|
+
env = dict(os.environ)
|
|
198
|
+
env["DEVTORCH_DAEMON_ROOT"] = str(project_root)
|
|
199
|
+
|
|
200
|
+
if popen_factory is None:
|
|
201
|
+
popen_factory = subprocess.Popen
|
|
202
|
+
|
|
203
|
+
with open(log_path, "a", encoding="utf-8") as log_f:
|
|
204
|
+
process = popen_factory(
|
|
205
|
+
cmd,
|
|
206
|
+
stdout=log_f,
|
|
207
|
+
stderr=subprocess.STDOUT,
|
|
208
|
+
stdin=subprocess.DEVNULL,
|
|
209
|
+
start_new_session=True,
|
|
210
|
+
env=env,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
new_pid = _wait_for_pid(gcc_dir)
|
|
214
|
+
if new_pid is not None:
|
|
215
|
+
return True, f"DevTorch daemon started (PID {new_pid})"
|
|
216
|
+
return False, (
|
|
217
|
+
f"Daemon subprocess launched (PID {process.pid}) but PID file "
|
|
218
|
+
f"was not written to {gcc_dir / PID_FILE_NAME}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def stop_daemon(project_root: Path) -> tuple[bool, str]:
|
|
223
|
+
"""Stop the daemon and clean up its PID file.
|
|
224
|
+
|
|
225
|
+
Returns (success, message). If no daemon is running, returns a message
|
|
226
|
+
explaining that and cleans up any stale PID file.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
gcc_dir = _gcc_dir(project_root)
|
|
230
|
+
except RuntimeError as exc:
|
|
231
|
+
return False, str(exc)
|
|
232
|
+
|
|
233
|
+
pid = _read_pid(gcc_dir)
|
|
234
|
+
pid_path = gcc_dir / PID_FILE_NAME
|
|
235
|
+
|
|
236
|
+
if pid is None:
|
|
237
|
+
return False, "DevTorch daemon is not running"
|
|
238
|
+
|
|
239
|
+
if not _is_alive(pid):
|
|
240
|
+
pid_path.unlink(missing_ok=True)
|
|
241
|
+
return False, f"Daemon PID {pid} is not alive; cleaned up stale PID file"
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
os.kill(pid, signal.SIGTERM)
|
|
245
|
+
except (OSError, ProcessLookupError):
|
|
246
|
+
pid_path.unlink(missing_ok=True)
|
|
247
|
+
return False, f"Failed to signal daemon PID {pid}; cleaned up PID file"
|
|
248
|
+
|
|
249
|
+
for _ in range(20):
|
|
250
|
+
if not _is_alive(pid):
|
|
251
|
+
break
|
|
252
|
+
time.sleep(0.1)
|
|
253
|
+
|
|
254
|
+
if _is_alive(pid):
|
|
255
|
+
try:
|
|
256
|
+
os.kill(pid, signal.SIGKILL)
|
|
257
|
+
time.sleep(0.1)
|
|
258
|
+
except (OSError, ProcessLookupError):
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
pid_path.unlink(missing_ok=True)
|
|
262
|
+
return True, f"DevTorch daemon stopped (PID {pid})"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def daemon_status(project_root: Path) -> dict[str, Any]:
|
|
266
|
+
"""Return a status dict for the daemon."""
|
|
267
|
+
try:
|
|
268
|
+
gcc_dir = _gcc_dir(project_root)
|
|
269
|
+
except RuntimeError as exc:
|
|
270
|
+
return {"running": False, "pid": None, "log_path": None, "error": str(exc)}
|
|
271
|
+
|
|
272
|
+
pid = _read_pid(gcc_dir)
|
|
273
|
+
log_path = gcc_dir / LOG_FILE_NAME
|
|
274
|
+
if pid is None:
|
|
275
|
+
return {"running": False, "pid": None, "log_path": str(log_path)}
|
|
276
|
+
alive = _is_alive(pid)
|
|
277
|
+
if not alive:
|
|
278
|
+
return {"running": False, "pid": pid, "log_path": str(log_path)}
|
|
279
|
+
return {"running": True, "pid": pid, "log_path": str(log_path)}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def daemon_logs(project_root: Path, lines: int = 50) -> list[str]:
|
|
283
|
+
"""Return the last N lines of the daemon log file."""
|
|
284
|
+
try:
|
|
285
|
+
gcc_dir = _gcc_dir(project_root)
|
|
286
|
+
except RuntimeError:
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
log_path = gcc_dir / LOG_FILE_NAME
|
|
290
|
+
if not log_path.exists():
|
|
291
|
+
return []
|
|
292
|
+
with log_path.open("r", encoding="utf-8") as f:
|
|
293
|
+
return f.read().splitlines()[-lines:]
|