arkaos 2.12.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.
@@ -170,6 +170,108 @@ fi
170
170
 
171
171
  ) 200>"$LOCK_FILE"
172
172
 
173
+ # ─── Workflow Violation Detection ────────────────────────────────────────
174
+ VIOLATION_MSG=""
175
+ STATE_READER=""
176
+ [ -f "$HOME/.arkaos/.repo-path" ] && STATE_READER="$(cat "$HOME/.arkaos/.repo-path")/core/workflow/state_reader.sh"
177
+
178
+ if [ -n "$STATE_READER" ] && [ -f "$STATE_READER" ] && bash "$STATE_READER" active 2>/dev/null; then
179
+ ARKAOS_PY=""
180
+ [ -f "$HOME/.arkaos/venv/bin/python3" ] && ARKAOS_PY="$HOME/.arkaos/venv/bin/python3"
181
+ [ -z "$ARKAOS_PY" ] && [ -f "$HOME/.arkaos/.venv/bin/python3" ] && ARKAOS_PY="$HOME/.arkaos/.venv/bin/python3"
182
+ [ -z "$ARKAOS_PY" ] && ARKAOS_PY=$(command -v python3 2>/dev/null)
183
+ ARKAOS_ROOT=$(cat "$HOME/.arkaos/.repo-path" 2>/dev/null)
184
+
185
+ # Rule 1: Branch isolation — commit on master while workflow active
186
+ if [ "$TOOL_NAME" = "Bash" ]; then
187
+ if echo "$TOOL_OUTPUT" | grep -qE '^\[(master|main)' 2>/dev/null; then
188
+ CMD_TEXT=$(echo "$input" | jq -r '.command // ""' 2>/dev/null)
189
+ if echo "$CMD_TEXT" | grep -qE 'git commit'; then
190
+ [ -n "$ARKAOS_PY" ] && [ -n "$ARKAOS_ROOT" ] && \
191
+ PYTHONPATH="$ARKAOS_ROOT" $ARKAOS_PY -c "
192
+ from core.workflow.state import add_violation
193
+ add_violation('branch-isolation', 'Commit on master/main while workflow active', 'Bash')
194
+ " 2>/dev/null
195
+ VIOLATION_MSG="VIOLATION [branch-isolation]: Commit on master while workflow active. Use a feature branch."
196
+ fi
197
+ fi
198
+ fi
199
+
200
+ # Rule 2: Spec-driven — code edited without completed spec
201
+ if [ "$TOOL_NAME" = "Write" ] || [ "$TOOL_NAME" = "Edit" ]; then
202
+ FILE_PATH=$(echo "$input" | jq -r '.file_path // ""' 2>/dev/null)
203
+ if echo "$FILE_PATH" | grep -qE '\.(py|js|ts|vue|php|jsx|tsx)$'; then
204
+ if ! bash "$STATE_READER" check spec 2>/dev/null; then
205
+ [ -n "$ARKAOS_PY" ] && [ -n "$ARKAOS_ROOT" ] && \
206
+ PYTHONPATH="$ARKAOS_ROOT" _V_TOOL="$TOOL_NAME" _V_FILE="$FILE_PATH" $ARKAOS_PY -c "
207
+ import os; from core.workflow.state import add_violation
208
+ add_violation('spec-driven', 'Code edited without completed spec', os.environ['_V_TOOL'], os.environ['_V_FILE'])
209
+ " 2>/dev/null
210
+ VIOLATION_MSG="VIOLATION [spec-driven]: Code edited without completed spec ($FILE_PATH). Complete the spec phase first."
211
+ fi
212
+ fi
213
+ fi
214
+
215
+ # Rule 3: Sequential — implementation before planning
216
+ if [ -z "$VIOLATION_MSG" ] && { [ "$TOOL_NAME" = "Write" ] || [ "$TOOL_NAME" = "Edit" ]; }; then
217
+ FILE_PATH=$(echo "$input" | jq -r '.file_path // ""' 2>/dev/null)
218
+ if echo "$FILE_PATH" | grep -qE '\.(py|js|ts|vue|php|jsx|tsx)$'; then
219
+ IMPL_STATUS=$(bash "$STATE_READER" phase implementation 2>/dev/null)
220
+ if [ "$IMPL_STATUS" = "pending" ]; then
221
+ [ -n "$ARKAOS_PY" ] && [ -n "$ARKAOS_ROOT" ] && \
222
+ PYTHONPATH="$ARKAOS_ROOT" _V_TOOL="$TOOL_NAME" _V_FILE="$FILE_PATH" $ARKAOS_PY -c "
223
+ import os; from core.workflow.state import add_violation
224
+ add_violation('sequential-validation', 'Code written before implementation phase started', os.environ['_V_TOOL'], os.environ['_V_FILE'])
225
+ " 2>/dev/null
226
+ VIOLATION_MSG="VIOLATION [sequential-validation]: Implementation started before planning completed ($FILE_PATH)."
227
+ fi
228
+ fi
229
+ fi
230
+ fi
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
+
173
275
  # ─── Log Metrics ─────────────────────────────────────────────────────────
174
276
  _DURATION_MS=$(_hook_ms)
175
277
  METRICS_FILE="$HOME/.arkaos/hook-metrics.json"
@@ -184,5 +286,9 @@ mkdir -p "$HOME/.arkaos"
184
286
  "$METRICS_FILE" > "$METRICS_FILE.tmp" 2>/dev/null && mv "$METRICS_FILE.tmp" "$METRICS_FILE"
185
287
  ) 200>"$METRICS_LOCK" 2>/dev/null
186
288
 
187
- # Silent output no context injection needed from PostToolUse
188
- echo '{}'
289
+ # Output violation as context if detected, otherwise empty
290
+ if [ -n "$VIOLATION_MSG" ]; then
291
+ echo "{\"additionalContext\": \"$VIOLATION_MSG\"}"
292
+ else
293
+ echo '{}'
294
+ fi
@@ -49,7 +49,45 @@ MSG+="║ ║\\n"
49
49
  MSG+="╚══════════════════════════════════════════════╝\\n"
50
50
  MSG+="\\n"
51
51
  MSG+="${GREETING}, ${NAME} (${COMPANY})\\n"
52
+ # ─── Active Workflow ──────────────────────────────────────────────────
53
+ STATE_READER="$REPO/core/workflow/state_reader.sh"
54
+ if [ -n "$REPO" ] && [ -f "$STATE_READER" ] && bash "$STATE_READER" active 2>/dev/null; then
55
+ WF_SUMMARY=$(bash "$STATE_READER" summary 2>/dev/null)
56
+ WF_NAME=$(echo "$WF_SUMMARY" | cut -d'|' -f1)
57
+ WF_PHASE=$(echo "$WF_SUMMARY" | cut -d'|' -f2)
58
+ WF_PROGRESS=$(echo "$WF_SUMMARY" | cut -d'|' -f3)
59
+ WF_BRANCH=$(echo "$WF_SUMMARY" | cut -d'|' -f4)
60
+ WF_VIOLATIONS=$(echo "$WF_SUMMARY" | cut -d'|' -f5)
61
+ MSG+="\\nWorkflow: ${WF_NAME} (${WF_PROGRESS})"
62
+ [ -n "$WF_BRANCH" ] && MSG+=" branch:${WF_BRANCH}"
63
+ [ "$WF_VIOLATIONS" != "0" ] && MSG+=" VIOLATIONS:${WF_VIOLATIONS}"
64
+ MSG+="\\n"
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
+
52
89
  MSG+="ArkaOS v${VERSION} | 65 agents | 17 departments | 244+ skills"
90
+ [ -n "$_FORGE_LINE" ] && MSG+="\\n${_FORGE_LINE}"
53
91
  MSG+="${DRIFT}"
54
92
 
55
93
  # ─── Output as systemMessage (same protocol as claude-mem) ─────────────
@@ -99,6 +99,31 @@ if command -v python3 &>/dev/null && [ -f "$BRIDGE_SCRIPT" ]; then
99
99
  if [ -n "$bridge_output" ]; then
100
100
  python_result=$(echo "$bridge_output" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('context_string',''))" 2>/dev/null)
101
101
  fi
102
+
103
+ # Append workflow state to synapse context
104
+ _WF_READER="$ARKAOS_ROOT/core/workflow/state_reader.sh"
105
+ if [ -f "$_WF_READER" ] && bash "$_WF_READER" active 2>/dev/null; then
106
+ _WF_SUM=$(bash "$_WF_READER" summary 2>/dev/null)
107
+ _WF_N=$(echo "$_WF_SUM" | cut -d'|' -f1)
108
+ _WF_P=$(echo "$_WF_SUM" | cut -d'|' -f2)
109
+ _WF_B=$(echo "$_WF_SUM" | cut -d'|' -f4)
110
+ _WF_V=$(echo "$_WF_SUM" | cut -d'|' -f5)
111
+ _WF_TAG="[workflow:${_WF_N}] [phase:${_WF_P}] [branch:${_WF_B}] [violations:${_WF_V}]"
112
+ [ "$_WF_V" != "0" ] && _WF_TAG="WARNING: ${_WF_V} workflow violation(s). $_WF_TAG"
113
+ python_result="${python_result} ${_WF_TAG}"
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
102
127
  fi
103
128
 
104
129
  # ─── Fallback: Bash-only context (if Python unavailable) ────────────────
@@ -130,7 +155,32 @@ if [ -z "$python_result" ]; then
130
155
  L7="[time:evening]"
131
156
  fi
132
157
 
133
- python_result="$L0 $L4 $L7"
158
+ # L8: Workflow state
159
+ L8=""
160
+ _WF_READER="$ARKAOS_ROOT/core/workflow/state_reader.sh"
161
+ if [ -f "$_WF_READER" ] && bash "$_WF_READER" active 2>/dev/null; then
162
+ _WF_SUM=$(bash "$_WF_READER" summary 2>/dev/null)
163
+ _WF_N=$(echo "$_WF_SUM" | cut -d'|' -f1)
164
+ _WF_P=$(echo "$_WF_SUM" | cut -d'|' -f2)
165
+ _WF_B=$(echo "$_WF_SUM" | cut -d'|' -f4)
166
+ _WF_V=$(echo "$_WF_SUM" | cut -d'|' -f5)
167
+ L8="[workflow:${_WF_N}] [phase:${_WF_P}] [branch:${_WF_B}] [violations:${_WF_V}]"
168
+ [ "$_WF_V" != "0" ] && L8="WARNING: ${_WF_V} workflow violation(s). $L8"
169
+ fi
170
+
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"
134
184
  fi
135
185
 
136
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
+ }