arkaos 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -229,6 +229,49 @@ add_violation('sequential-validation', 'Code written before implementation phase
229
229
  fi
230
230
  fi
231
231
 
232
+ # --- Forge Violation Detection ---
233
+ _FORGE_ACTIVE="$HOME/.arkaos/plans/active.yaml"
234
+
235
+ # Ensure ARKAOS_PY and ARKAOS_ROOT are set (may not be set if no active workflow)
236
+ if [ -z "${ARKAOS_PY:-}" ]; then
237
+ [ -f "$HOME/.arkaos/venv/bin/python3" ] && ARKAOS_PY="$HOME/.arkaos/venv/bin/python3"
238
+ [ -z "${ARKAOS_PY:-}" ] && [ -f "$HOME/.arkaos/.venv/bin/python3" ] && ARKAOS_PY="$HOME/.arkaos/.venv/bin/python3"
239
+ [ -z "${ARKAOS_PY:-}" ] && ARKAOS_PY=$(command -v python3 2>/dev/null)
240
+ fi
241
+ [ -z "${ARKAOS_ROOT:-}" ] && ARKAOS_ROOT=$(cat "$HOME/.arkaos/.repo-path" 2>/dev/null)
242
+
243
+ if [ -z "$VIOLATION_MSG" ] && [ -f "$_FORGE_ACTIVE" ] && [ -n "$ARKAOS_PY" ] && [ -n "$ARKAOS_ROOT" ]; then
244
+ _FORGE_ID=$(cat "$_FORGE_ACTIVE" 2>/dev/null)
245
+ _FORGE_FILE="$HOME/.arkaos/plans/${_FORGE_ID}.yaml"
246
+
247
+ if [ -f "$_FORGE_FILE" ]; then
248
+ if [ "$TOOL_NAME" = "Edit" ] || [ "$TOOL_NAME" = "Write" ]; then
249
+ _EDITED_FILE="${tool_input_file_path:-}"
250
+ # Fallback: extract file_path from input JSON
251
+ [ -z "$_EDITED_FILE" ] && _EDITED_FILE=$(echo "$input" | jq -r '.file_path // ""' 2>/dev/null)
252
+ if [ -n "$_EDITED_FILE" ]; then
253
+ _FORGE_VIOLATION=$(FORGE_FILE="$_FORGE_FILE" EDITED_FILE="$_EDITED_FILE" PYTHONPATH="$ARKAOS_ROOT" $ARKAOS_PY -c "
254
+ import yaml, sys, os
255
+ plan = yaml.safe_load(open(os.environ['FORGE_FILE']))
256
+ if plan.get('status', '') != 'executing':
257
+ sys.exit(0)
258
+ phases = plan.get('plan_phases', [])
259
+ all_deliverables = []
260
+ for p in phases:
261
+ all_deliverables.extend(p.get('deliverables', []))
262
+ edited = os.environ['EDITED_FILE']
263
+ match = any(d in edited or edited.endswith(d) for d in all_deliverables)
264
+ if not match and all_deliverables:
265
+ print('forge-scope-creep')
266
+ " 2>/dev/null)
267
+ if [ "$_FORGE_VIOLATION" = "forge-scope-creep" ]; then
268
+ VIOLATION_MSG="⚠ Forge scope-creep: editing ${_EDITED_FILE} which is outside forge plan deliverables."
269
+ fi
270
+ fi
271
+ fi
272
+ fi
273
+ fi
274
+
232
275
  # ─── Log Metrics ─────────────────────────────────────────────────────────
233
276
  _DURATION_MS=$(_hook_ms)
234
277
  METRICS_FILE="$HOME/.arkaos/hook-metrics.json"
@@ -63,7 +63,31 @@ if [ -n "$REPO" ] && [ -f "$STATE_READER" ] && bash "$STATE_READER" active 2>/de
63
63
  [ "$WF_VIOLATIONS" != "0" ] && MSG+=" VIOLATIONS:${WF_VIOLATIONS}"
64
64
  MSG+="\\n"
65
65
  fi
66
+
67
+ # --- Forge Plan Display ---
68
+ _FORGE_PLANS="$HOME/.arkaos/plans"
69
+ _FORGE_ACTIVE="$_FORGE_PLANS/active.yaml"
70
+ _FORGE_LINE=""
71
+
72
+ if [ -f "$_FORGE_ACTIVE" ]; then
73
+ _FORGE_ID=$(cat "$_FORGE_ACTIVE" 2>/dev/null)
74
+ _FORGE_FILE="$_FORGE_PLANS/${_FORGE_ID}.yaml"
75
+ if [ -f "$_FORGE_FILE" ] && command -v python3 &>/dev/null; then
76
+ _FORGE_NAME=$(FORGE_FILE="$_FORGE_FILE" python3 -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('name',''))" 2>/dev/null)
77
+ _FORGE_STATUS=$(FORGE_FILE="$_FORGE_FILE" python3 -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('status',''))" 2>/dev/null)
78
+ _FORGE_PHASES=$(FORGE_FILE="$_FORGE_FILE" python3 -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(len(d.get('plan_phases',[])))" 2>/dev/null)
79
+ _FORGE_BRANCH=$(FORGE_FILE="$_FORGE_FILE" python3 -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('governance',{}).get('branch_strategy',''))" 2>/dev/null)
80
+
81
+ if [ "$_FORGE_STATUS" = "approved" ]; then
82
+ _FORGE_LINE=" ⚒ Forge plan pending: ${_FORGE_NAME} | Phases: ${_FORGE_PHASES} | /forge resume"
83
+ elif [ "$_FORGE_STATUS" = "executing" ]; then
84
+ _FORGE_LINE=" ⚒ Forge executing: ${_FORGE_NAME} | Phases: ${_FORGE_PHASES} | Branch: ${_FORGE_BRANCH}"
85
+ fi
86
+ fi
87
+ fi
88
+
66
89
  MSG+="ArkaOS v${VERSION} | 65 agents | 17 departments | 244+ skills"
90
+ [ -n "$_FORGE_LINE" ] && MSG+="\\n${_FORGE_LINE}"
67
91
  MSG+="${DRIFT}"
68
92
 
69
93
  # ─── Output as systemMessage (same protocol as claude-mem) ─────────────
@@ -112,6 +112,18 @@ if command -v python3 &>/dev/null && [ -f "$BRIDGE_SCRIPT" ]; then
112
112
  [ "$_WF_V" != "0" ] && _WF_TAG="WARNING: ${_WF_V} workflow violation(s). $_WF_TAG"
113
113
  python_result="${python_result} ${_WF_TAG}"
114
114
  fi
115
+
116
+ # --- Forge Context Injection ---
117
+ _FORGE_ACTIVE="$HOME/.arkaos/plans/active.yaml"
118
+ if [ -f "$_FORGE_ACTIVE" ]; then
119
+ _FORGE_ID=$(cat "$_FORGE_ACTIVE" 2>/dev/null)
120
+ _FORGE_FILE="$HOME/.arkaos/plans/${_FORGE_ID}.yaml"
121
+ if [ -f "$_FORGE_FILE" ] && command -v python3 &>/dev/null; then
122
+ _FORGE_STATUS=$(FORGE_FILE="$_FORGE_FILE" python3 -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('status',''))" 2>/dev/null)
123
+ _FORGE_TAG="[forge:${_FORGE_ID}] [forge-status:${_FORGE_STATUS}]"
124
+ python_result="${python_result} ${_FORGE_TAG}"
125
+ fi
126
+ fi
115
127
  fi
116
128
 
117
129
  # ─── Fallback: Bash-only context (if Python unavailable) ────────────────
@@ -156,7 +168,19 @@ if [ -z "$python_result" ]; then
156
168
  [ "$_WF_V" != "0" ] && L8="WARNING: ${_WF_V} workflow violation(s). $L8"
157
169
  fi
158
170
 
159
- python_result="$L0 $L4 $L7 $L8"
171
+ # L9: Forge state
172
+ L9=""
173
+ _FORGE_ACTIVE_FB="$HOME/.arkaos/plans/active.yaml"
174
+ if [ -f "$_FORGE_ACTIVE_FB" ]; then
175
+ _FORGE_ID_FB=$(cat "$_FORGE_ACTIVE_FB" 2>/dev/null)
176
+ _FORGE_FILE_FB="$HOME/.arkaos/plans/${_FORGE_ID_FB}.yaml"
177
+ if [ -f "$_FORGE_FILE_FB" ] && command -v python3 &>/dev/null; then
178
+ _FORGE_STATUS_FB=$(FORGE_FILE="$_FORGE_FILE_FB" python3 -c "import yaml,os; d=yaml.safe_load(open(os.environ['FORGE_FILE'])); print(d.get('status',''))" 2>/dev/null)
179
+ L9="[forge:${_FORGE_ID_FB}] [forge-status:${_FORGE_STATUS_FB}]"
180
+ fi
181
+ fi
182
+
183
+ python_result="$L0 $L4 $L7 $L8 $L9"
160
184
  fi
161
185
 
162
186
  # ─── Output ──────────────────────────────────────────────────────────────
@@ -0,0 +1,104 @@
1
+ """The Forge — ArkaOS Intelligent Planning Engine."""
2
+
3
+ from core.forge.schema import (
4
+ ForgeTier,
5
+ ForgeStatus,
6
+ ExplorerLens,
7
+ RiskSeverity,
8
+ ExecutionPathType,
9
+ ComplexityDimensions,
10
+ ComplexityScore,
11
+ KeyDecision,
12
+ PhaseDeliverable,
13
+ ExplorerApproach,
14
+ RejectedElement,
15
+ IdentifiedRisk,
16
+ CriticVerdict,
17
+ ForgeContext,
18
+ PlanPhase,
19
+ ExecutionPath,
20
+ ForgeGovernance,
21
+ ForgePlan,
22
+ )
23
+
24
+ from core.forge.complexity import (
25
+ score_dimensions,
26
+ calculate_weighted_score,
27
+ determine_tier,
28
+ analyze_complexity,
29
+ )
30
+
31
+ from core.forge.persistence import (
32
+ save_plan,
33
+ load_plan,
34
+ list_plans,
35
+ get_active_plan,
36
+ set_active_plan,
37
+ clear_active_plan,
38
+ export_to_obsidian,
39
+ extract_patterns,
40
+ load_patterns,
41
+ )
42
+
43
+ from core.forge.renderer import (
44
+ render_complexity,
45
+ render_critic_summary,
46
+ render_plan_overview,
47
+ render_terminal,
48
+ render_html,
49
+ should_suggest_companion,
50
+ )
51
+
52
+ from core.forge.handoff import (
53
+ select_execution_path,
54
+ generate_workflow_yaml,
55
+ check_repo_drift,
56
+ )
57
+
58
+ __all__ = [
59
+ # schema
60
+ "ForgeTier",
61
+ "ForgeStatus",
62
+ "ExplorerLens",
63
+ "RiskSeverity",
64
+ "ExecutionPathType",
65
+ "ComplexityDimensions",
66
+ "ComplexityScore",
67
+ "KeyDecision",
68
+ "PhaseDeliverable",
69
+ "ExplorerApproach",
70
+ "RejectedElement",
71
+ "IdentifiedRisk",
72
+ "CriticVerdict",
73
+ "ForgeContext",
74
+ "PlanPhase",
75
+ "ExecutionPath",
76
+ "ForgeGovernance",
77
+ "ForgePlan",
78
+ # complexity
79
+ "score_dimensions",
80
+ "calculate_weighted_score",
81
+ "determine_tier",
82
+ "analyze_complexity",
83
+ # persistence
84
+ "save_plan",
85
+ "load_plan",
86
+ "list_plans",
87
+ "get_active_plan",
88
+ "set_active_plan",
89
+ "clear_active_plan",
90
+ "export_to_obsidian",
91
+ "extract_patterns",
92
+ "load_patterns",
93
+ # renderer
94
+ "render_complexity",
95
+ "render_critic_summary",
96
+ "render_plan_overview",
97
+ "render_terminal",
98
+ "render_html",
99
+ "should_suggest_companion",
100
+ # handoff
101
+ "select_execution_path",
102
+ "generate_workflow_yaml",
103
+ "check_repo_drift",
104
+ ]
@@ -0,0 +1,125 @@
1
+ """Forge complexity scorer — 5-dimension analysis with tier determination."""
2
+
3
+ import re
4
+
5
+ from core.forge.schema import ComplexityDimensions, ComplexityScore, ForgeTier
6
+
7
+ _WEIGHTS = {
8
+ "scope": 0.30,
9
+ "dependencies": 0.25,
10
+ "ambiguity": 0.20,
11
+ "risk": 0.15,
12
+ "novelty": 0.10,
13
+ }
14
+
15
+ _RISK_KEYWORDS = re.compile(
16
+ r"\b(auth\w*|security\w*|encrypt\w*|password\w*|token\w*|secret\w*|permission\w*|migration\w*|database\w*|schema\w*|deploy\w*|infra\w*|production\w*|payment\w*|billing\w*)",
17
+ re.IGNORECASE,
18
+ )
19
+ _VAGUE_PATTERNS = re.compile(
20
+ r"\b(fix|improve|make.*better|update|change|refactor|clean|optimize)\b",
21
+ re.IGNORECASE,
22
+ )
23
+
24
+
25
+ def score_dimensions(
26
+ prompt: str,
27
+ affected_files: list[str],
28
+ departments: list[str],
29
+ similar_plans: list[str],
30
+ reused_patterns: list[str],
31
+ ) -> ComplexityDimensions:
32
+ """Score all 5 complexity dimensions from prompt and context."""
33
+ return ComplexityDimensions(
34
+ scope=_score_scope(affected_files, departments),
35
+ dependencies=_score_dependencies(affected_files),
36
+ ambiguity=_score_ambiguity(prompt, affected_files),
37
+ risk=_score_risk(prompt, affected_files),
38
+ novelty=_score_novelty(similar_plans, reused_patterns),
39
+ )
40
+
41
+
42
+ def calculate_weighted_score(dims: ComplexityDimensions) -> int:
43
+ """Calculate weighted total score from dimensions."""
44
+ total = (
45
+ dims.scope * _WEIGHTS["scope"]
46
+ + dims.dependencies * _WEIGHTS["dependencies"]
47
+ + dims.ambiguity * _WEIGHTS["ambiguity"]
48
+ + dims.risk * _WEIGHTS["risk"]
49
+ + dims.novelty * _WEIGHTS["novelty"]
50
+ )
51
+ return round(total)
52
+
53
+
54
+ def determine_tier(score: int) -> ForgeTier:
55
+ """Map a 0-100 score to a forge tier."""
56
+ if score <= 30:
57
+ return ForgeTier.SHALLOW
58
+ if score <= 65:
59
+ return ForgeTier.STANDARD
60
+ return ForgeTier.DEEP
61
+
62
+
63
+ def analyze_complexity(
64
+ prompt: str,
65
+ affected_files: list[str],
66
+ departments: list[str],
67
+ similar_plans: list[str],
68
+ reused_patterns: list[str],
69
+ ) -> ComplexityScore:
70
+ """Full complexity analysis: dimensions, weighted score, tier."""
71
+ dims = score_dimensions(prompt, affected_files, departments, similar_plans, reused_patterns)
72
+ score = calculate_weighted_score(dims)
73
+ tier = determine_tier(score)
74
+ return ComplexityScore(
75
+ score=score,
76
+ tier=tier,
77
+ dimensions=dims,
78
+ similar_plans=similar_plans,
79
+ reused_patterns=reused_patterns,
80
+ )
81
+
82
+
83
+ def _score_scope(files: list[str], departments: list[str]) -> int:
84
+ file_score = min(100, len(files) * 10)
85
+ dept_score = min(100, len(departments) * 30)
86
+ return min(100, (file_score + dept_score) // 2)
87
+
88
+
89
+ def _score_dependencies(files: list[str]) -> int:
90
+ if not files:
91
+ return 20
92
+ core_files = sum(1 for f in files if f.startswith("core/"))
93
+ ratio = core_files / len(files)
94
+ return min(100, int(ratio * 80) + 20)
95
+
96
+
97
+ def _score_ambiguity(prompt: str, files: list[str]) -> int:
98
+ score = 50
99
+ if len(prompt.split()) < 5:
100
+ score += 30
101
+ if not files:
102
+ score += 20
103
+ vague_matches = len(_VAGUE_PATTERNS.findall(prompt))
104
+ score += min(20, vague_matches * 10)
105
+ specific_indicators = len(re.findall(r"[/\.]", prompt))
106
+ score -= min(30, specific_indicators * 5)
107
+ return max(0, min(100, score))
108
+
109
+
110
+ def _score_risk(prompt: str, files: list[str]) -> int:
111
+ score = 20
112
+ risk_matches = len(_RISK_KEYWORDS.findall(prompt))
113
+ score += min(50, risk_matches * 15)
114
+ sensitive_paths = sum(
115
+ 1 for f in files if any(kw in f for kw in ("auth", "security", "migration", "deploy", "config"))
116
+ )
117
+ score += min(30, sensitive_paths * 15)
118
+ return min(100, score)
119
+
120
+
121
+ def _score_novelty(similar_plans: list[str], patterns: list[str]) -> int:
122
+ score = 90
123
+ score -= min(40, len(similar_plans) * 20)
124
+ score -= min(30, len(patterns) * 15)
125
+ return max(10, score)
@@ -0,0 +1,100 @@
1
+ """Forge handoff — execution path routing and workflow generation."""
2
+
3
+ import subprocess
4
+ import yaml
5
+ from core.forge.schema import ExecutionPath, ExecutionPathType, ForgePlan, PlanPhase
6
+
7
+
8
+ def select_execution_path(phases: list[PlanPhase]) -> ExecutionPath:
9
+ """Select the execution path based on phase count and departments.
10
+
11
+ Rules: 1 phase, 1 dept → skill | 2-3 phases, 1 dept → workflow | 4+ phases or 2+ depts → enterprise
12
+ """
13
+ departments = list({p.department for p in phases})
14
+ n_phases = len(phases)
15
+ n_depts = len(departments)
16
+
17
+ if n_depts >= 2:
18
+ path_type = ExecutionPathType.ENTERPRISE_WORKFLOW
19
+ elif n_phases >= 4:
20
+ path_type = ExecutionPathType.ENTERPRISE_WORKFLOW
21
+ elif n_phases >= 2:
22
+ path_type = ExecutionPathType.WORKFLOW
23
+ else:
24
+ path_type = ExecutionPathType.SKILL
25
+
26
+ target = ""
27
+ if path_type == ExecutionPathType.SKILL and departments:
28
+ target = f"arka-{departments[0]}"
29
+ elif path_type in (ExecutionPathType.WORKFLOW, ExecutionPathType.ENTERPRISE_WORKFLOW):
30
+ target = "generated-workflow.yaml"
31
+
32
+ return ExecutionPath(
33
+ type=path_type,
34
+ target=target,
35
+ departments=departments,
36
+ estimated_commits=max(1, n_phases * 2),
37
+ )
38
+
39
+
40
+ def generate_workflow_yaml(plan: ForgePlan) -> str:
41
+ """Generate a complete workflow YAML from a forge plan."""
42
+ phases_data = []
43
+ for phase in plan.plan_phases:
44
+ d: dict = {"name": phase.name, "department": phase.department}
45
+ if phase.agents:
46
+ d["agents"] = [{"role": a} for a in phase.agents]
47
+ if phase.deliverables:
48
+ d["deliverables"] = phase.deliverables
49
+ if phase.acceptance_criteria:
50
+ d["acceptance_criteria"] = phase.acceptance_criteria
51
+ if phase.depends_on:
52
+ d["depends_on"] = phase.depends_on
53
+ if phase.context_from_forge:
54
+ d["context_from_forge"] = phase.context_from_forge
55
+ phases_data.append(d)
56
+
57
+ workflow = {
58
+ "name": plan.id,
59
+ "type": "enterprise" if len(plan.plan_phases) >= 4 else "focused",
60
+ "generated_by": "forge",
61
+ "forge_plan_id": plan.id,
62
+ "phases": phases_data,
63
+ "quality_gate_required": plan.governance.quality_gate_required,
64
+ "branch": plan.governance.branch_strategy,
65
+ }
66
+ return yaml.dump(workflow, default_flow_style=False, allow_unicode=True)
67
+
68
+
69
+ def _git_changed_files(base: str, current: str) -> list[str]:
70
+ """Return list of files changed between two commits, or [] on error."""
71
+ try:
72
+ result = subprocess.run(
73
+ ["git", "diff", "--name-only", base, current],
74
+ capture_output=True, text=True, check=True,
75
+ )
76
+ return [f for f in result.stdout.strip().split("\n") if f]
77
+ except subprocess.CalledProcessError:
78
+ return []
79
+
80
+
81
+ def check_repo_drift(commit_at_forge: str) -> dict:
82
+ """Check if the repo has changed since the forge snapshot."""
83
+ try:
84
+ result = subprocess.run(
85
+ ["git", "rev-parse", "HEAD"],
86
+ capture_output=True, text=True, check=True,
87
+ )
88
+ current = result.stdout.strip()
89
+ except (subprocess.CalledProcessError, FileNotFoundError):
90
+ return {"changed": False, "files": [], "message": "Could not read git state"}
91
+
92
+ if current == commit_at_forge:
93
+ return {"changed": False, "files": [], "message": "Repo unchanged"}
94
+
95
+ files = _git_changed_files(commit_at_forge, current)
96
+ return {
97
+ "changed": True,
98
+ "files": files,
99
+ "message": f"Repo changed: {len(files)} files modified since forge",
100
+ }