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.
- package/VERSION +1 -1
- package/arka/skills/forge/SKILL.md +649 -0
- package/config/constitution.yaml +8 -0
- package/config/hooks/post-tool-use.sh +43 -0
- package/config/hooks/session-start.sh +24 -0
- package/config/hooks/user-prompt-submit.sh +25 -1
- package/core/forge/__init__.py +104 -0
- package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/handoff.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/forge/complexity.py +125 -0
- package/core/forge/handoff.py +100 -0
- package/core/forge/persistence.py +308 -0
- package/core/forge/renderer.py +261 -0
- package/core/forge/schema.py +213 -0
- package/core/synapse/__init__.py +2 -2
- package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +4 -2
- package/core/synapse/layers.py +49 -0
- package/core/workflow/state_reader.sh +25 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -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
|
-
|
|
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
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
}
|