arkaos 2.0.1 → 2.0.2
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/VERSION +1 -1
- package/config/constitution.yaml +4 -0
- package/config/hooks/user-prompt-submit-v2.sh +20 -38
- package/core/budget/__init__.py +6 -0
- package/core/budget/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/budget/__pycache__/manager.cpython-313.pyc +0 -0
- package/core/budget/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/budget/manager.py +193 -0
- package/core/budget/schema.py +82 -0
- package/core/obsidian/__init__.py +6 -0
- package/core/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/templates.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
- package/core/obsidian/templates.py +76 -0
- package/core/obsidian/writer.py +148 -0
- package/core/orchestration/__init__.py +6 -0
- package/core/orchestration/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/orchestration/__pycache__/patterns.cpython-313.pyc +0 -0
- package/core/orchestration/__pycache__/protocol.cpython-313.pyc +0 -0
- package/core/orchestration/patterns.py +136 -0
- package/core/orchestration/protocol.py +96 -0
- package/core/tasks/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/tasks/schema.py +6 -0
- package/core/workflow/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/workflow/engine.py +44 -0
- package/core/workflow/schema.py +1 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.0.
|
|
1
|
+
2.0.2
|
package/config/constitution.yaml
CHANGED
|
@@ -118,6 +118,10 @@ enforcement_levels:
|
|
|
118
118
|
- id: complexity-assessment
|
|
119
119
|
rule: "Assess task complexity before starting. Route to appropriate workflow tier."
|
|
120
120
|
|
|
121
|
+
- id: communication-standard
|
|
122
|
+
rule: "Bottom-line first output. Lead with answer, then why, then how. Confidence tags on assessments."
|
|
123
|
+
enforcement: "See config/standards/communication.md for full standard"
|
|
124
|
+
|
|
121
125
|
tier_hierarchy:
|
|
122
126
|
description: "Agent authority levels inspired by SpaceX/Google/Anthropic org structures"
|
|
123
127
|
tiers:
|
|
@@ -19,7 +19,18 @@ _hook_ms() {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
# ─── Paths ───────────────────────────────────────────────────────────────
|
|
22
|
-
ARKAOS_ROOT
|
|
22
|
+
# Resolve ARKAOS_ROOT: env var → .repo-path → npm package → fallback
|
|
23
|
+
if [ -n "${ARKAOS_ROOT:-}" ]; then
|
|
24
|
+
: # already set
|
|
25
|
+
elif [ -f "$HOME/.arkaos/.repo-path" ]; then
|
|
26
|
+
ARKAOS_ROOT=$(cat "$HOME/.arkaos/.repo-path")
|
|
27
|
+
elif [ -d "$HOME/.arkaos" ]; then
|
|
28
|
+
ARKAOS_ROOT="$HOME/.arkaos"
|
|
29
|
+
else
|
|
30
|
+
ARKAOS_ROOT="${ARKA_OS:-$HOME/.claude/skills/arkaos}"
|
|
31
|
+
fi
|
|
32
|
+
export ARKAOS_ROOT
|
|
33
|
+
|
|
23
34
|
CACHE_DIR="/tmp/arkaos-context-cache"
|
|
24
35
|
CACHE_TTL=300 # Constitution cache: 5 minutes
|
|
25
36
|
|
|
@@ -35,46 +46,17 @@ if [ -z "$user_input" ]; then
|
|
|
35
46
|
user_input=$(echo "$input" | head -c 2000)
|
|
36
47
|
fi
|
|
37
48
|
|
|
38
|
-
# ─── Try Python Synapse
|
|
49
|
+
# ─── Try Python Synapse bridge first ────────────────────────────────────
|
|
39
50
|
python_result=""
|
|
40
|
-
|
|
41
|
-
python_result=$(python3 -c "
|
|
42
|
-
import sys, os
|
|
43
|
-
sys.path.insert(0, '${ARKAOS_ROOT}')
|
|
44
|
-
try:
|
|
45
|
-
from core.synapse.engine import create_default_engine
|
|
46
|
-
from core.synapse.layers import PromptContext
|
|
47
|
-
from core.governance.constitution import load_constitution
|
|
48
|
-
import subprocess
|
|
51
|
+
BRIDGE_SCRIPT="${ARKAOS_ROOT}/scripts/synapse-bridge.py"
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if os.path.exists(const_path):
|
|
54
|
-
c = load_constitution(const_path)
|
|
55
|
-
compressed = c.compress_for_context()
|
|
53
|
+
if command -v python3 &>/dev/null && [ -f "$BRIDGE_SCRIPT" ]; then
|
|
54
|
+
bridge_output=$(echo "{\"user_input\":$(echo "$user_input" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null || echo '""')}" \
|
|
55
|
+
| ARKAOS_ROOT="$ARKAOS_ROOT" python3 "$BRIDGE_SCRIPT" --root "$ARKAOS_ROOT" 2>/dev/null)
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
branch = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
61
|
-
capture_output=True, text=True, timeout=2).stdout.strip()
|
|
62
|
-
except Exception:
|
|
63
|
-
pass
|
|
64
|
-
|
|
65
|
-
# Create engine and inject
|
|
66
|
-
engine = create_default_engine(constitution_compressed=compressed)
|
|
67
|
-
ctx = PromptContext(
|
|
68
|
-
user_input='''${user_input}''',
|
|
69
|
-
cwd=os.getcwd(),
|
|
70
|
-
git_branch=branch,
|
|
71
|
-
)
|
|
72
|
-
result = engine.inject(ctx)
|
|
73
|
-
print(result.context_string)
|
|
74
|
-
except Exception as e:
|
|
75
|
-
print(f'[arkaos:error] {e}', file=sys.stderr)
|
|
76
|
-
print('')
|
|
77
|
-
" 2>/dev/null)
|
|
57
|
+
if [ -n "$bridge_output" ]; then
|
|
58
|
+
python_result=$(echo "$bridge_output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('context_string',''))" 2>/dev/null)
|
|
59
|
+
fi
|
|
78
60
|
fi
|
|
79
61
|
|
|
80
62
|
# ─── Fallback: Bash-only context (if Python unavailable) ────────────────
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Token budget system — tracking and enforcement for agent operations."""
|
|
2
|
+
|
|
3
|
+
from core.budget.schema import BudgetConfig, BudgetUsage, BudgetSummary, TierBudget
|
|
4
|
+
from core.budget.manager import BudgetManager
|
|
5
|
+
|
|
6
|
+
__all__ = ["BudgetConfig", "BudgetUsage", "BudgetSummary", "TierBudget", "BudgetManager"]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Budget manager — track, enforce, and report on token budgets."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from core.budget.schema import BudgetConfig, BudgetSummary, BudgetUsage, TierBudget
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BudgetManager:
|
|
12
|
+
"""Manages token budget allocation, tracking, and enforcement.
|
|
13
|
+
|
|
14
|
+
Budgets are tier-based with monthly reset. Usage is persisted to JSON.
|
|
15
|
+
Tier 0 agents have unlimited budgets. Other tiers need Tier 0 approval
|
|
16
|
+
when usage exceeds the approval threshold (default 80%).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, storage_path: str | Path = "", config: BudgetConfig | None = None) -> None:
|
|
20
|
+
self._config = config or BudgetConfig()
|
|
21
|
+
self._usages: list[BudgetUsage] = []
|
|
22
|
+
self._counter: int = 0
|
|
23
|
+
self._storage_path = Path(storage_path) if storage_path else None
|
|
24
|
+
if self._storage_path and self._storage_path.exists():
|
|
25
|
+
self._load()
|
|
26
|
+
|
|
27
|
+
def record_usage(
|
|
28
|
+
self,
|
|
29
|
+
agent_id: str,
|
|
30
|
+
tokens: int,
|
|
31
|
+
tier: int = 2,
|
|
32
|
+
department: str = "",
|
|
33
|
+
workflow_id: str = "",
|
|
34
|
+
task_id: str = "",
|
|
35
|
+
description: str = "",
|
|
36
|
+
approved_by: str = "",
|
|
37
|
+
) -> BudgetUsage:
|
|
38
|
+
"""Record a token usage event."""
|
|
39
|
+
self._counter += 1
|
|
40
|
+
usage = BudgetUsage(
|
|
41
|
+
id=f"usage-{self._counter:06d}",
|
|
42
|
+
agent_id=agent_id,
|
|
43
|
+
department=department,
|
|
44
|
+
tier=tier,
|
|
45
|
+
tokens=tokens,
|
|
46
|
+
workflow_id=workflow_id,
|
|
47
|
+
task_id=task_id,
|
|
48
|
+
description=description,
|
|
49
|
+
timestamp=datetime.now().isoformat(),
|
|
50
|
+
approved_by=approved_by,
|
|
51
|
+
)
|
|
52
|
+
self._usages.append(usage)
|
|
53
|
+
self._save()
|
|
54
|
+
return usage
|
|
55
|
+
|
|
56
|
+
def get_period_usage(self, tier: int, department: str = "") -> int:
|
|
57
|
+
"""Get total tokens used this billing period for a tier/department."""
|
|
58
|
+
period_start = self._current_period_start()
|
|
59
|
+
total = 0
|
|
60
|
+
for u in self._usages:
|
|
61
|
+
if u.tier != tier:
|
|
62
|
+
continue
|
|
63
|
+
if department and u.department != department:
|
|
64
|
+
continue
|
|
65
|
+
if u.timestamp and u.timestamp >= period_start.isoformat():
|
|
66
|
+
total += u.tokens
|
|
67
|
+
return total
|
|
68
|
+
|
|
69
|
+
def get_remaining(self, tier: int, department: str = "") -> int:
|
|
70
|
+
"""Get remaining tokens for this period. Returns -1 for unlimited."""
|
|
71
|
+
budget = self._config.get_tier_budget(tier)
|
|
72
|
+
if budget.is_unlimited:
|
|
73
|
+
return -1
|
|
74
|
+
used = self.get_period_usage(tier, department)
|
|
75
|
+
return max(0, budget.monthly_tokens - used)
|
|
76
|
+
|
|
77
|
+
def check_budget(self, tier: int, estimated_tokens: int, department: str = "") -> bool:
|
|
78
|
+
"""Check if there's enough budget for an operation. True = OK."""
|
|
79
|
+
budget = self._config.get_tier_budget(tier)
|
|
80
|
+
if budget.is_unlimited:
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
# Check per-task limit
|
|
84
|
+
if budget.per_task_max > 0 and estimated_tokens > budget.per_task_max:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
# Check remaining monthly budget
|
|
88
|
+
remaining = self.get_remaining(tier, department)
|
|
89
|
+
return estimated_tokens <= remaining
|
|
90
|
+
|
|
91
|
+
def needs_approval(self, tier: int, department: str = "") -> bool:
|
|
92
|
+
"""Check if usage has exceeded the approval threshold."""
|
|
93
|
+
budget = self._config.get_tier_budget(tier)
|
|
94
|
+
if budget.is_unlimited:
|
|
95
|
+
return False
|
|
96
|
+
used = self.get_period_usage(tier, department)
|
|
97
|
+
return used >= (budget.monthly_tokens * budget.approval_threshold)
|
|
98
|
+
|
|
99
|
+
def get_summary(self, tier: int, department: str = "") -> BudgetSummary:
|
|
100
|
+
"""Get a complete budget summary for a tier/department."""
|
|
101
|
+
budget = self._config.get_tier_budget(tier)
|
|
102
|
+
used = self.get_period_usage(tier, department)
|
|
103
|
+
period_start = self._current_period_start()
|
|
104
|
+
|
|
105
|
+
if budget.is_unlimited:
|
|
106
|
+
return BudgetSummary(
|
|
107
|
+
tier=tier,
|
|
108
|
+
department=department,
|
|
109
|
+
period_start=period_start.isoformat(),
|
|
110
|
+
allocated=0,
|
|
111
|
+
used=used,
|
|
112
|
+
remaining=-1,
|
|
113
|
+
percent_used=0,
|
|
114
|
+
is_unlimited=True,
|
|
115
|
+
usage_count=self._count_period_usages(tier, department),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
remaining = max(0, budget.monthly_tokens - used)
|
|
119
|
+
percent = (used / budget.monthly_tokens * 100) if budget.monthly_tokens > 0 else 0
|
|
120
|
+
overruns = sum(
|
|
121
|
+
1 for u in self._usages
|
|
122
|
+
if u.tier == tier
|
|
123
|
+
and (not department or u.department == department)
|
|
124
|
+
and u.timestamp >= period_start.isoformat()
|
|
125
|
+
and u.is_overrun_approved
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return BudgetSummary(
|
|
129
|
+
tier=tier,
|
|
130
|
+
department=department,
|
|
131
|
+
period_start=period_start.isoformat(),
|
|
132
|
+
allocated=budget.monthly_tokens,
|
|
133
|
+
used=used,
|
|
134
|
+
remaining=remaining,
|
|
135
|
+
percent_used=round(percent, 1),
|
|
136
|
+
is_unlimited=False,
|
|
137
|
+
needs_approval=self.needs_approval(tier, department),
|
|
138
|
+
usage_count=self._count_period_usages(tier, department),
|
|
139
|
+
overruns=overruns,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def reset_monthly(self) -> int:
|
|
143
|
+
"""Archive old usages and start a new billing period. Returns archived count."""
|
|
144
|
+
period_start = self._current_period_start()
|
|
145
|
+
old = [u for u in self._usages if u.timestamp < period_start.isoformat()]
|
|
146
|
+
self._usages = [u for u in self._usages if u.timestamp >= period_start.isoformat()]
|
|
147
|
+
self._save()
|
|
148
|
+
return len(old)
|
|
149
|
+
|
|
150
|
+
def _current_period_start(self) -> date:
|
|
151
|
+
"""Get the start of the current billing period."""
|
|
152
|
+
today = date.today()
|
|
153
|
+
day = self._config.billing_start_day
|
|
154
|
+
if today.day >= day:
|
|
155
|
+
return date(today.year, today.month, day)
|
|
156
|
+
# Previous month
|
|
157
|
+
month = today.month - 1
|
|
158
|
+
year = today.year
|
|
159
|
+
if month < 1:
|
|
160
|
+
month = 12
|
|
161
|
+
year -= 1
|
|
162
|
+
return date(year, month, day)
|
|
163
|
+
|
|
164
|
+
def _count_period_usages(self, tier: int, department: str = "") -> int:
|
|
165
|
+
period_start = self._current_period_start()
|
|
166
|
+
return sum(
|
|
167
|
+
1 for u in self._usages
|
|
168
|
+
if u.tier == tier
|
|
169
|
+
and (not department or u.department == department)
|
|
170
|
+
and u.timestamp >= period_start.isoformat()
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _save(self) -> None:
|
|
174
|
+
if self._storage_path is None:
|
|
175
|
+
return
|
|
176
|
+
self._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
data = {
|
|
178
|
+
"counter": self._counter,
|
|
179
|
+
"usages": [u.model_dump(mode="json") for u in self._usages],
|
|
180
|
+
}
|
|
181
|
+
with open(self._storage_path, "w") as f:
|
|
182
|
+
json.dump(data, f, indent=2)
|
|
183
|
+
|
|
184
|
+
def _load(self) -> None:
|
|
185
|
+
if self._storage_path is None or not self._storage_path.exists():
|
|
186
|
+
return
|
|
187
|
+
content = self._storage_path.read_text().strip()
|
|
188
|
+
if not content:
|
|
189
|
+
return
|
|
190
|
+
data = json.loads(content)
|
|
191
|
+
self._counter = data.get("counter", 0)
|
|
192
|
+
for udata in data.get("usages", []):
|
|
193
|
+
self._usages.append(BudgetUsage.model_validate(udata))
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Budget schema — token allocation, usage tracking, and summaries."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TierBudget(BaseModel):
|
|
10
|
+
"""Token budget for a single agent tier."""
|
|
11
|
+
tier: int
|
|
12
|
+
monthly_tokens: int # 0 = unlimited
|
|
13
|
+
per_task_max: int # 0 = unlimited
|
|
14
|
+
approval_threshold: float = 0.8 # Needs Tier 0 approval at this % used
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def is_unlimited(self) -> bool:
|
|
18
|
+
return self.monthly_tokens == 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Default tier budgets (configurable via BudgetConfig)
|
|
22
|
+
DEFAULT_TIER_BUDGETS = {
|
|
23
|
+
0: TierBudget(tier=0, monthly_tokens=0, per_task_max=0), # Unlimited
|
|
24
|
+
1: TierBudget(tier=1, monthly_tokens=5_000_000, per_task_max=500_000),
|
|
25
|
+
2: TierBudget(tier=2, monthly_tokens=2_000_000, per_task_max=200_000),
|
|
26
|
+
3: TierBudget(tier=3, monthly_tokens=1_000_000, per_task_max=100_000),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BudgetConfig(BaseModel):
|
|
31
|
+
"""Budget configuration for the system."""
|
|
32
|
+
tier_budgets: dict[int, TierBudget] = Field(default_factory=lambda: dict(DEFAULT_TIER_BUDGETS))
|
|
33
|
+
billing_start_day: int = 1 # Day of month billing resets
|
|
34
|
+
|
|
35
|
+
def get_tier_budget(self, tier: int) -> TierBudget:
|
|
36
|
+
return self.tier_budgets.get(tier, DEFAULT_TIER_BUDGETS.get(tier, TierBudget(tier=tier, monthly_tokens=1_000_000, per_task_max=100_000)))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BudgetUsage(BaseModel):
|
|
40
|
+
"""A single token usage record."""
|
|
41
|
+
id: str
|
|
42
|
+
agent_id: str
|
|
43
|
+
department: str = ""
|
|
44
|
+
tier: int = 2
|
|
45
|
+
tokens: int = 0
|
|
46
|
+
workflow_id: str = ""
|
|
47
|
+
task_id: str = ""
|
|
48
|
+
description: str = ""
|
|
49
|
+
timestamp: str = ""
|
|
50
|
+
approved_by: str = "" # Tier 0 agent who approved overrun
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_overrun_approved(self) -> bool:
|
|
54
|
+
return bool(self.approved_by)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BudgetSummary(BaseModel):
|
|
58
|
+
"""Current budget status for a tier or department."""
|
|
59
|
+
tier: int
|
|
60
|
+
department: str = ""
|
|
61
|
+
period_start: str = ""
|
|
62
|
+
period_end: str = ""
|
|
63
|
+
allocated: int = 0 # Monthly allocation
|
|
64
|
+
used: int = 0 # Tokens used this period
|
|
65
|
+
remaining: int = 0 # Tokens remaining
|
|
66
|
+
percent_used: float = 0.0 # 0-100
|
|
67
|
+
is_unlimited: bool = False
|
|
68
|
+
needs_approval: bool = False # >80% threshold
|
|
69
|
+
usage_count: int = 0 # Number of operations this period
|
|
70
|
+
overruns: int = 0 # Number of approved overruns
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def status(self) -> str:
|
|
74
|
+
if self.is_unlimited:
|
|
75
|
+
return "UNLIMITED"
|
|
76
|
+
if self.percent_used >= 100:
|
|
77
|
+
return "EXCEEDED"
|
|
78
|
+
if self.needs_approval:
|
|
79
|
+
return "APPROVAL_REQUIRED"
|
|
80
|
+
if self.percent_used >= 50:
|
|
81
|
+
return "MODERATE"
|
|
82
|
+
return "HEALTHY"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Obsidian frontmatter templates and conventions."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_frontmatter(
|
|
8
|
+
department: str = "",
|
|
9
|
+
agent: str = "",
|
|
10
|
+
workflow: str = "",
|
|
11
|
+
tags: list[str] | None = None,
|
|
12
|
+
extra: dict[str, Any] | None = None,
|
|
13
|
+
) -> str:
|
|
14
|
+
"""Build YAML frontmatter for an Obsidian note.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
String with opening/closing --- delimiters.
|
|
18
|
+
"""
|
|
19
|
+
fields: dict[str, Any] = {}
|
|
20
|
+
fields["created"] = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
21
|
+
fields["source"] = "arkaos"
|
|
22
|
+
|
|
23
|
+
if department:
|
|
24
|
+
fields["department"] = department
|
|
25
|
+
if agent:
|
|
26
|
+
fields["agent"] = agent
|
|
27
|
+
if workflow:
|
|
28
|
+
fields["workflow"] = workflow
|
|
29
|
+
|
|
30
|
+
all_tags = ["arkaos"]
|
|
31
|
+
if department:
|
|
32
|
+
all_tags.append(f"dept/{department}")
|
|
33
|
+
if tags:
|
|
34
|
+
all_tags.extend(tags)
|
|
35
|
+
fields["tags"] = all_tags
|
|
36
|
+
|
|
37
|
+
if extra:
|
|
38
|
+
fields.update(extra)
|
|
39
|
+
|
|
40
|
+
lines = ["---"]
|
|
41
|
+
for key, value in fields.items():
|
|
42
|
+
if isinstance(value, list):
|
|
43
|
+
lines.append(f"{key}:")
|
|
44
|
+
for item in value:
|
|
45
|
+
lines.append(f" - {item}")
|
|
46
|
+
elif isinstance(value, bool):
|
|
47
|
+
lines.append(f"{key}: {'true' if value else 'false'}")
|
|
48
|
+
else:
|
|
49
|
+
lines.append(f"{key}: {value}")
|
|
50
|
+
lines.append("---")
|
|
51
|
+
|
|
52
|
+
return "\n".join(lines)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_template_vars(path: str, vars: dict[str, str] | None = None) -> str:
|
|
56
|
+
"""Resolve template variables in an Obsidian path.
|
|
57
|
+
|
|
58
|
+
Supported variables:
|
|
59
|
+
{project}, {department}, {agent}, {date}, {number}, {name}
|
|
60
|
+
"""
|
|
61
|
+
defaults = {
|
|
62
|
+
"date": date.today().isoformat(),
|
|
63
|
+
"project": "Default",
|
|
64
|
+
"department": "general",
|
|
65
|
+
"agent": "unknown",
|
|
66
|
+
"number": "001",
|
|
67
|
+
"name": "untitled",
|
|
68
|
+
}
|
|
69
|
+
if vars:
|
|
70
|
+
defaults.update(vars)
|
|
71
|
+
|
|
72
|
+
result = path
|
|
73
|
+
for key, value in defaults.items():
|
|
74
|
+
result = result.replace(f"{{{key}}}", value)
|
|
75
|
+
|
|
76
|
+
return result
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Obsidian vault writer — save workflow outputs to knowledge base.
|
|
2
|
+
|
|
3
|
+
Resolves vault path from:
|
|
4
|
+
1. Constructor argument
|
|
5
|
+
2. knowledge/obsidian-config.json → vault_path
|
|
6
|
+
3. ARKAOS_VAULT environment variable
|
|
7
|
+
4. Fallback: ~/.arkaos/vault/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from core.obsidian.templates import build_frontmatter, resolve_template_vars
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ObsidianWriter:
|
|
20
|
+
"""Writes markdown files to an Obsidian vault with frontmatter."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, vault_path: str | Path | None = None, arkaos_root: str | Path | None = None) -> None:
|
|
23
|
+
self._vault_path = self._resolve_vault_path(vault_path, arkaos_root)
|
|
24
|
+
|
|
25
|
+
def save(
|
|
26
|
+
self,
|
|
27
|
+
obsidian_path: str,
|
|
28
|
+
content: str,
|
|
29
|
+
department: str = "",
|
|
30
|
+
agent: str = "",
|
|
31
|
+
workflow: str = "",
|
|
32
|
+
tags: list[str] | None = None,
|
|
33
|
+
template_vars: dict[str, str] | None = None,
|
|
34
|
+
extra_frontmatter: dict[str, Any] | None = None,
|
|
35
|
+
) -> Path:
|
|
36
|
+
"""Save content to the Obsidian vault.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
obsidian_path: Relative path within vault (may contain template vars).
|
|
40
|
+
content: Markdown content to save.
|
|
41
|
+
department: Source department.
|
|
42
|
+
agent: Agent that produced the output.
|
|
43
|
+
workflow: Workflow that generated this output.
|
|
44
|
+
tags: Additional tags for frontmatter.
|
|
45
|
+
template_vars: Variables to resolve in path ({project}, {date}, etc.).
|
|
46
|
+
extra_frontmatter: Additional frontmatter fields.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Absolute path to the saved file.
|
|
50
|
+
"""
|
|
51
|
+
# Resolve template variables in path
|
|
52
|
+
resolved_path = resolve_template_vars(obsidian_path, template_vars)
|
|
53
|
+
|
|
54
|
+
# Build full path
|
|
55
|
+
full_path = self._vault_path / resolved_path
|
|
56
|
+
|
|
57
|
+
# If path doesn't end with .md, treat as directory and add filename
|
|
58
|
+
if not full_path.suffix:
|
|
59
|
+
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M")
|
|
60
|
+
filename = f"{department or 'output'}-{timestamp}.md"
|
|
61
|
+
full_path = full_path / filename
|
|
62
|
+
elif full_path.suffix != ".md":
|
|
63
|
+
full_path = full_path.with_suffix(".md")
|
|
64
|
+
|
|
65
|
+
# Handle duplicate filenames
|
|
66
|
+
if full_path.exists():
|
|
67
|
+
stem = full_path.stem
|
|
68
|
+
timestamp = datetime.now().strftime("%H%M%S")
|
|
69
|
+
full_path = full_path.with_stem(f"{stem}-{timestamp}")
|
|
70
|
+
|
|
71
|
+
# Create directories
|
|
72
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
# Build frontmatter
|
|
75
|
+
frontmatter = build_frontmatter(
|
|
76
|
+
department=department,
|
|
77
|
+
agent=agent,
|
|
78
|
+
workflow=workflow,
|
|
79
|
+
tags=tags,
|
|
80
|
+
extra=extra_frontmatter,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Write file
|
|
84
|
+
file_content = f"{frontmatter}\n\n{content}"
|
|
85
|
+
full_path.write_text(file_content, encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
return full_path
|
|
88
|
+
|
|
89
|
+
def ensure_vault(self) -> bool:
|
|
90
|
+
"""Verify the vault directory exists."""
|
|
91
|
+
return self._vault_path.exists()
|
|
92
|
+
|
|
93
|
+
def list_outputs(self, department: str = "", limit: int = 50) -> list[Path]:
|
|
94
|
+
"""List recent ArkaOS outputs in the vault."""
|
|
95
|
+
if not self._vault_path.exists():
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
pattern = "**/*.md"
|
|
99
|
+
files = sorted(self._vault_path.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
100
|
+
|
|
101
|
+
results = []
|
|
102
|
+
for f in files:
|
|
103
|
+
if len(results) >= limit:
|
|
104
|
+
break
|
|
105
|
+
try:
|
|
106
|
+
head = f.read_text(encoding="utf-8")[:200]
|
|
107
|
+
if "source: arkaos" in head:
|
|
108
|
+
if not department or f"department: {department}" in head:
|
|
109
|
+
results.append(f)
|
|
110
|
+
except (OSError, UnicodeDecodeError):
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
return results
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def vault_path(self) -> Path:
|
|
117
|
+
return self._vault_path
|
|
118
|
+
|
|
119
|
+
def _resolve_vault_path(self, explicit: str | Path | None, arkaos_root: str | Path | None) -> Path:
|
|
120
|
+
"""Resolve vault path from multiple sources."""
|
|
121
|
+
# 1. Explicit argument
|
|
122
|
+
if explicit:
|
|
123
|
+
return Path(explicit)
|
|
124
|
+
|
|
125
|
+
# 2. Config file
|
|
126
|
+
if arkaos_root:
|
|
127
|
+
config_path = Path(arkaos_root) / "knowledge" / "obsidian-config.json"
|
|
128
|
+
else:
|
|
129
|
+
config_path = Path(__file__).resolve().parent.parent.parent / "knowledge" / "obsidian-config.json"
|
|
130
|
+
|
|
131
|
+
if config_path.exists():
|
|
132
|
+
try:
|
|
133
|
+
config = json.loads(config_path.read_text())
|
|
134
|
+
vault = config.get("vault_path", "")
|
|
135
|
+
if vault and Path(vault).exists():
|
|
136
|
+
return Path(vault)
|
|
137
|
+
except (json.JSONDecodeError, OSError):
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
# 3. Environment variable
|
|
141
|
+
env_vault = os.environ.get("ARKAOS_VAULT", "")
|
|
142
|
+
if env_vault and Path(env_vault).exists():
|
|
143
|
+
return Path(env_vault)
|
|
144
|
+
|
|
145
|
+
# 4. Fallback
|
|
146
|
+
fallback = Path.home() / ".arkaos" / "vault"
|
|
147
|
+
fallback.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
return fallback
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Orchestration protocol — coordination patterns for multi-agent work."""
|
|
2
|
+
|
|
3
|
+
from core.orchestration.protocol import OrchestrationPattern, PhaseHandoff, OrchestrationPlan
|
|
4
|
+
from core.orchestration.patterns import PATTERNS
|
|
5
|
+
|
|
6
|
+
__all__ = ["OrchestrationPattern", "PhaseHandoff", "OrchestrationPlan", "PATTERNS"]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Built-in orchestration patterns.
|
|
2
|
+
|
|
3
|
+
Four patterns adapted from claude-skills orchestration protocol,
|
|
4
|
+
mapped to ArkaOS department/agent/skill system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from core.orchestration.protocol import OrchestrationPattern, PatternType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SOLO_SPRINT = OrchestrationPattern(
|
|
11
|
+
type=PatternType.SOLO_SPRINT,
|
|
12
|
+
name="Solo Sprint",
|
|
13
|
+
description="One department lead drives a multi-phase sprint, pulling skills from their squad. Fast, focused execution.",
|
|
14
|
+
when_to_use=[
|
|
15
|
+
"Single-domain task with clear scope",
|
|
16
|
+
"Time-constrained delivery (< 1 week)",
|
|
17
|
+
"One department has all required expertise",
|
|
18
|
+
],
|
|
19
|
+
structure="Lead → Phase 1 (skills A,B) → Phase 2 (skills C,D) → Quality Gate → Deliver",
|
|
20
|
+
example=(
|
|
21
|
+
"Objective: Launch MVP landing page\n"
|
|
22
|
+
"Lead: Ines (Landing)\n"
|
|
23
|
+
"Phase 1: /landing copy-framework + /landing funnel-design\n"
|
|
24
|
+
"Phase 2: /landing page-build + /landing seo-optimize\n"
|
|
25
|
+
"Phase 3: Quality Gate → Ship"
|
|
26
|
+
),
|
|
27
|
+
anti_patterns=[
|
|
28
|
+
"Using for cross-department work (use Multi-Agent Handoff instead)",
|
|
29
|
+
"Skipping Quality Gate because 'it's just one department'",
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
DOMAIN_DEEP_DIVE = OrchestrationPattern(
|
|
34
|
+
type=PatternType.DOMAIN_DEEP_DIVE,
|
|
35
|
+
name="Domain Deep-Dive",
|
|
36
|
+
description="One agent, multiple stacked skills for thorough analysis. Deep expertise over breadth.",
|
|
37
|
+
when_to_use=[
|
|
38
|
+
"Complex technical audit or review",
|
|
39
|
+
"Due diligence or compliance assessment",
|
|
40
|
+
"Research requiring depth in one area",
|
|
41
|
+
],
|
|
42
|
+
structure="Agent → Skill 1 (foundation) → Skill 2 (analysis) → Skill 3 (recommendations) → Report",
|
|
43
|
+
example=(
|
|
44
|
+
"Objective: Full security assessment\n"
|
|
45
|
+
"Agent: Bruno (Security Engineer)\n"
|
|
46
|
+
"Skills stacked:\n"
|
|
47
|
+
" 1. /dev security-audit (OWASP scan)\n"
|
|
48
|
+
" 2. /dev dependency-audit (CVE check)\n"
|
|
49
|
+
" 3. /dev red-team (attack simulation)\n"
|
|
50
|
+
" 4. /dev ai-security (LLM-specific risks)\n"
|
|
51
|
+
"Output: Consolidated security report with severity rankings"
|
|
52
|
+
),
|
|
53
|
+
anti_patterns=[
|
|
54
|
+
"Stacking unrelated skills (each skill should build on previous)",
|
|
55
|
+
"Skipping the foundational skill and going straight to advanced",
|
|
56
|
+
"No consolidated output (each skill reports independently)",
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
MULTI_AGENT_HANDOFF = OrchestrationPattern(
|
|
61
|
+
type=PatternType.MULTI_AGENT_HANDOFF,
|
|
62
|
+
name="Multi-Agent Handoff",
|
|
63
|
+
description="Work flows between departments with structured handoffs. Each department adds its expertise.",
|
|
64
|
+
when_to_use=[
|
|
65
|
+
"Cross-department projects (product launch, brand + marketing)",
|
|
66
|
+
"Sequential expertise needed (strategy → dev → marketing)",
|
|
67
|
+
"Each phase requires different domain knowledge",
|
|
68
|
+
],
|
|
69
|
+
structure="Dept A → Handoff → Dept B → Handoff → Dept C → Quality Gate → Deliver",
|
|
70
|
+
example=(
|
|
71
|
+
"Objective: Launch new SaaS product\n"
|
|
72
|
+
"Phase 1: Tomas (Strategy) → /strat bmc + /strat five-forces\n"
|
|
73
|
+
" Handoff: Market validation, pricing strategy, competitive position\n"
|
|
74
|
+
"Phase 2: Paulo (Dev) → /dev spec + /dev feature\n"
|
|
75
|
+
" Handoff: MVP built, deployed to staging\n"
|
|
76
|
+
"Phase 3: Luna (Marketing) → /mkt growth-plan + /mkt email-sequence\n"
|
|
77
|
+
" Handoff: Landing page, email funnel, launch plan\n"
|
|
78
|
+
"Phase 4: Quality Gate → Launch"
|
|
79
|
+
),
|
|
80
|
+
anti_patterns=[
|
|
81
|
+
"No handoff context (next department starts from zero)",
|
|
82
|
+
"Too many departments (>4 phases becomes unwieldy)",
|
|
83
|
+
"Skipping departments that should review (e.g., security for a public launch)",
|
|
84
|
+
],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
SKILL_CHAIN = OrchestrationPattern(
|
|
88
|
+
type=PatternType.SKILL_CHAIN,
|
|
89
|
+
name="Skill Chain",
|
|
90
|
+
description="Sequential procedural skills with no specific agent identity. Pure execution pipeline.",
|
|
91
|
+
when_to_use=[
|
|
92
|
+
"Procedural work with well-defined inputs/outputs",
|
|
93
|
+
"Automated pipelines (generate → validate → publish)",
|
|
94
|
+
"No judgment calls needed, just execution",
|
|
95
|
+
],
|
|
96
|
+
structure="Skill A (input) → Skill B (transform) → Skill C (output) → Done",
|
|
97
|
+
example=(
|
|
98
|
+
"Objective: Content production pipeline\n"
|
|
99
|
+
"Chain:\n"
|
|
100
|
+
" 1. /content hook-write → 10 headline options\n"
|
|
101
|
+
" 2. python scripts/tools/headline_scorer.py → scored + ranked\n"
|
|
102
|
+
" 3. /content viral-design → full post with winning hook\n"
|
|
103
|
+
" 4. /mkt social-strategy → distribution plan\n"
|
|
104
|
+
"No persona needed — pure skill execution"
|
|
105
|
+
),
|
|
106
|
+
anti_patterns=[
|
|
107
|
+
"Using for work that needs judgment (use Solo Sprint or Handoff)",
|
|
108
|
+
"Long chains with no validation checkpoints",
|
|
109
|
+
"Mixing judgment skills with procedural skills",
|
|
110
|
+
],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
PATTERNS = {
|
|
115
|
+
PatternType.SOLO_SPRINT: SOLO_SPRINT,
|
|
116
|
+
PatternType.DOMAIN_DEEP_DIVE: DOMAIN_DEEP_DIVE,
|
|
117
|
+
PatternType.MULTI_AGENT_HANDOFF: MULTI_AGENT_HANDOFF,
|
|
118
|
+
PatternType.SKILL_CHAIN: SKILL_CHAIN,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def select_pattern(
|
|
123
|
+
departments_involved: int,
|
|
124
|
+
needs_judgment: bool,
|
|
125
|
+
is_sequential: bool,
|
|
126
|
+
) -> PatternType:
|
|
127
|
+
"""Recommend an orchestration pattern based on task characteristics."""
|
|
128
|
+
if departments_involved == 1 and needs_judgment:
|
|
129
|
+
return PatternType.SOLO_SPRINT
|
|
130
|
+
if departments_involved == 1 and not needs_judgment:
|
|
131
|
+
return PatternType.SKILL_CHAIN
|
|
132
|
+
if departments_involved > 1 and is_sequential:
|
|
133
|
+
return PatternType.MULTI_AGENT_HANDOFF
|
|
134
|
+
if departments_involved == 1:
|
|
135
|
+
return PatternType.DOMAIN_DEEP_DIVE
|
|
136
|
+
return PatternType.MULTI_AGENT_HANDOFF
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Orchestration protocol schemas.
|
|
2
|
+
|
|
3
|
+
Defines the structure for multi-agent coordination across departments.
|
|
4
|
+
Four patterns: Solo Sprint, Domain Deep-Dive, Multi-Agent Handoff, Skill Chain.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PatternType(str, Enum):
|
|
14
|
+
SOLO_SPRINT = "solo_sprint"
|
|
15
|
+
DOMAIN_DEEP_DIVE = "domain_deep_dive"
|
|
16
|
+
MULTI_AGENT_HANDOFF = "multi_agent_handoff"
|
|
17
|
+
SKILL_CHAIN = "skill_chain"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PhaseHandoff(BaseModel):
|
|
21
|
+
"""Context passed between orchestration phases."""
|
|
22
|
+
phase_number: int
|
|
23
|
+
phase_name: str
|
|
24
|
+
decisions: list[str] = Field(default_factory=list)
|
|
25
|
+
artifacts: list[str] = Field(default_factory=list)
|
|
26
|
+
open_questions: list[str] = Field(default_factory=list)
|
|
27
|
+
next_department: str = ""
|
|
28
|
+
next_agent: str = ""
|
|
29
|
+
next_skills: list[str] = Field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
def to_context(self) -> str:
|
|
32
|
+
"""Render handoff as context string for next phase."""
|
|
33
|
+
lines = [f"Phase {self.phase_number} ({self.phase_name}) complete."]
|
|
34
|
+
if self.decisions:
|
|
35
|
+
lines.append(f"Decisions: {', '.join(self.decisions)}")
|
|
36
|
+
if self.artifacts:
|
|
37
|
+
lines.append(f"Artifacts: {', '.join(self.artifacts)}")
|
|
38
|
+
if self.open_questions:
|
|
39
|
+
lines.append(f"Open questions: {', '.join(self.open_questions)}")
|
|
40
|
+
if self.next_department:
|
|
41
|
+
lines.append(f"Next: {self.next_department}/{self.next_agent}")
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OrchestrationPhase(BaseModel):
|
|
46
|
+
"""A single phase in an orchestration plan."""
|
|
47
|
+
number: int
|
|
48
|
+
name: str
|
|
49
|
+
department: str
|
|
50
|
+
agent_id: str
|
|
51
|
+
skills: list[str] = Field(default_factory=list)
|
|
52
|
+
objective: str = ""
|
|
53
|
+
outputs: list[str] = Field(default_factory=list)
|
|
54
|
+
gate: str = "user_approval" # user_approval, auto, quality_gate
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OrchestrationPlan(BaseModel):
|
|
58
|
+
"""A complete orchestration plan spanning multiple departments."""
|
|
59
|
+
objective: str
|
|
60
|
+
pattern: PatternType
|
|
61
|
+
constraints: list[str] = Field(default_factory=list)
|
|
62
|
+
success_criteria: list[str] = Field(default_factory=list)
|
|
63
|
+
phases: list[OrchestrationPhase] = Field(default_factory=list)
|
|
64
|
+
current_phase: int = 0
|
|
65
|
+
|
|
66
|
+
def next_phase(self) -> Optional[OrchestrationPhase]:
|
|
67
|
+
"""Get the next phase to execute."""
|
|
68
|
+
if self.current_phase < len(self.phases):
|
|
69
|
+
return self.phases[self.current_phase]
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def advance(self, handoff: PhaseHandoff) -> Optional[OrchestrationPhase]:
|
|
73
|
+
"""Record handoff and advance to next phase."""
|
|
74
|
+
self.current_phase += 1
|
|
75
|
+
return self.next_phase()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_complete(self) -> bool:
|
|
79
|
+
return self.current_phase >= len(self.phases)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def progress_percent(self) -> int:
|
|
83
|
+
if not self.phases:
|
|
84
|
+
return 0
|
|
85
|
+
return int((self.current_phase / len(self.phases)) * 100)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class OrchestrationPattern(BaseModel):
|
|
89
|
+
"""Definition of a coordination pattern."""
|
|
90
|
+
type: PatternType
|
|
91
|
+
name: str
|
|
92
|
+
description: str
|
|
93
|
+
when_to_use: list[str] = Field(default_factory=list)
|
|
94
|
+
structure: str = ""
|
|
95
|
+
example: str = ""
|
|
96
|
+
anti_patterns: list[str] = Field(default_factory=list)
|
|
Binary file
|
package/core/tasks/schema.py
CHANGED
|
@@ -47,6 +47,12 @@ class Task(BaseModel):
|
|
|
47
47
|
output_data: dict[str, Any] = Field(default_factory=dict)
|
|
48
48
|
output_path: str = "" # File path for output
|
|
49
49
|
|
|
50
|
+
# Budget
|
|
51
|
+
tokens_estimated: int = 0 # Pre-execution estimate
|
|
52
|
+
tokens_actual: int = 0 # Post-execution actual
|
|
53
|
+
budget_approved: bool = True # True if within budget or approved
|
|
54
|
+
approved_by: str = "" # Tier 0 agent who approved overrun
|
|
55
|
+
|
|
50
56
|
# Progress
|
|
51
57
|
progress_percent: int = 0 # 0-100
|
|
52
58
|
progress_message: str = ""
|
|
Binary file
|
|
Binary file
|
package/core/workflow/engine.py
CHANGED
|
@@ -48,6 +48,8 @@ class WorkflowEngine:
|
|
|
48
48
|
on_phase_complete: Optional[Callable[[Phase, PhaseResult], None]] = None,
|
|
49
49
|
on_gate_check: Optional[Callable[[Gate], GateResult]] = None,
|
|
50
50
|
on_visibility: Optional[Callable[[str], None]] = None,
|
|
51
|
+
budget_manager: Any = None,
|
|
52
|
+
obsidian_writer: Any = None,
|
|
51
53
|
):
|
|
52
54
|
"""Initialize the workflow engine.
|
|
53
55
|
|
|
@@ -61,6 +63,8 @@ class WorkflowEngine:
|
|
|
61
63
|
self._on_phase_complete = on_phase_complete
|
|
62
64
|
self._on_gate_check = on_gate_check
|
|
63
65
|
self._on_visibility = on_visibility
|
|
66
|
+
self._budget_manager = budget_manager
|
|
67
|
+
self._obsidian_writer = obsidian_writer
|
|
64
68
|
self._history: list[PhaseResult] = []
|
|
65
69
|
|
|
66
70
|
def announce(self, message: str) -> None:
|
|
@@ -144,6 +148,22 @@ class WorkflowEngine:
|
|
|
144
148
|
if self._on_phase_complete:
|
|
145
149
|
self._on_phase_complete(phase, result)
|
|
146
150
|
|
|
151
|
+
# Save outputs to Obsidian vault (NON-NEGOTIABLE: obsidian-output)
|
|
152
|
+
if self._obsidian_writer and hasattr(phase, "outputs"):
|
|
153
|
+
for output in getattr(phase, "outputs", []):
|
|
154
|
+
obsidian_path = getattr(output, "obsidian_path", "")
|
|
155
|
+
if obsidian_path and result.output:
|
|
156
|
+
try:
|
|
157
|
+
saved = self._obsidian_writer.save(
|
|
158
|
+
obsidian_path=obsidian_path,
|
|
159
|
+
content=result.output,
|
|
160
|
+
department=workflow.department,
|
|
161
|
+
workflow=workflow.id,
|
|
162
|
+
)
|
|
163
|
+
self.announce(f"Saved to vault: {saved.name}")
|
|
164
|
+
except Exception as e:
|
|
165
|
+
self.announce(f"Vault save failed: {e}")
|
|
166
|
+
|
|
147
167
|
self.announce(f"Phase {i}: {phase.name} — COMPLETED")
|
|
148
168
|
|
|
149
169
|
# Evaluate gate
|
|
@@ -189,12 +209,36 @@ class WorkflowEngine:
|
|
|
189
209
|
if gate.type == GateType.AUTO:
|
|
190
210
|
return GateResult(passed=True, gate_type=GateType.AUTO, message="Auto-pass")
|
|
191
211
|
|
|
212
|
+
if gate.type == GateType.BUDGET_CHECK and self._budget_manager:
|
|
213
|
+
return self._evaluate_budget_gate(gate)
|
|
214
|
+
|
|
192
215
|
if self._on_gate_check:
|
|
193
216
|
return self._on_gate_check(gate)
|
|
194
217
|
|
|
195
218
|
# Default: pass
|
|
196
219
|
return GateResult(passed=True, gate_type=gate.type, message="Default pass")
|
|
197
220
|
|
|
221
|
+
def _evaluate_budget_gate(self, gate: Gate) -> GateResult:
|
|
222
|
+
"""Evaluate a budget gate using the budget manager."""
|
|
223
|
+
# Default tier for budget checks (can be overridden via gate metadata)
|
|
224
|
+
tier = 2
|
|
225
|
+
estimated_tokens = 50_000 # Default estimate per phase
|
|
226
|
+
|
|
227
|
+
if self._budget_manager.check_budget(tier, estimated_tokens):
|
|
228
|
+
needs_approval = self._budget_manager.needs_approval(tier)
|
|
229
|
+
msg = "Budget OK"
|
|
230
|
+
if needs_approval:
|
|
231
|
+
msg = "Budget OK (>80% used — Tier 0 notified)"
|
|
232
|
+
self.announce(f"Budget warning: tier {tier} at >80% monthly usage")
|
|
233
|
+
return GateResult(passed=True, gate_type=GateType.BUDGET_CHECK, message=msg)
|
|
234
|
+
|
|
235
|
+
summary = self._budget_manager.get_summary(tier)
|
|
236
|
+
return GateResult(
|
|
237
|
+
passed=False,
|
|
238
|
+
gate_type=GateType.BUDGET_CHECK,
|
|
239
|
+
message=f"Budget exceeded: {summary.used}/{summary.allocated} tokens used ({summary.percent_used}%). Needs Tier 0 approval.",
|
|
240
|
+
)
|
|
241
|
+
|
|
198
242
|
def _evaluate_condition(self, condition: str) -> bool:
|
|
199
243
|
"""Evaluate a skip/branch condition.
|
|
200
244
|
|
package/core/workflow/schema.py
CHANGED
|
@@ -27,6 +27,7 @@ class GateType(str, Enum):
|
|
|
27
27
|
QUALITY_GATE = "quality_gate" # Marta + Eduardo + Francisca
|
|
28
28
|
AUTO = "auto" # Passes automatically if phase succeeds
|
|
29
29
|
CONDITION = "condition" # Passes if condition evaluates true
|
|
30
|
+
BUDGET_CHECK = "budget_check" # Verifies token budget before execution
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
class Gate(BaseModel):
|
package/package.json
CHANGED