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 CHANGED
@@ -1 +1 @@
1
- 2.0.1
1
+ 2.0.2
@@ -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="${ARKA_OS:-$HOME/.claude/skills/arkaos}"
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 engine first ─────────────────────────────────────
49
+ # ─── Try Python Synapse bridge first ────────────────────────────────────
39
50
  python_result=""
40
- if command -v python3 &>/dev/null; then
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
- # Load constitution for L0
51
- const_path = '${ARKAOS_ROOT}/config/constitution.yaml'
52
- compressed = ''
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
- # Detect git branch
58
- branch = ''
59
- try:
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"]
@@ -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"
@@ -0,0 +1,6 @@
1
+ """Obsidian vault integration — write workflow outputs to knowledge base."""
2
+
3
+ from core.obsidian.writer import ObsidianWriter
4
+ from core.obsidian.templates import build_frontmatter
5
+
6
+ __all__ = ["ObsidianWriter", "build_frontmatter"]
@@ -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"]
@@ -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)
@@ -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 = ""
@@ -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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.0.1"
3
+ version = "2.0.2"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}