loki-mode 5.58.2 → 6.0.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +34 -9
- package/autonomy/issue-providers.sh +423 -0
- package/autonomy/loki +1080 -31
- package/autonomy/run.sh +320 -3
- package/dashboard/__init__.py +1 -1
- package/dashboard/migration_engine.py +74 -64
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/providers/claude.sh +52 -2
- package/providers/codex.sh +39 -4
- package/providers/gemini.sh +44 -3
package/autonomy/run.sh
CHANGED
|
@@ -177,9 +177,9 @@ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
|
177
177
|
# Solution: Copy ourselves to /tmp and run from there. The original can be safely edited.
|
|
178
178
|
#===============================================================================
|
|
179
179
|
if [[ -z "${LOKI_RUNNING_FROM_TEMP:-}" ]] && [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
180
|
-
TEMP_SCRIPT
|
|
180
|
+
TEMP_SCRIPT=$(mktemp /tmp/loki-run-XXXXXX.sh)
|
|
181
181
|
cp "${BASH_SOURCE[0]}" "$TEMP_SCRIPT"
|
|
182
|
-
chmod
|
|
182
|
+
chmod 700 "$TEMP_SCRIPT"
|
|
183
183
|
export LOKI_RUNNING_FROM_TEMP=1
|
|
184
184
|
export LOKI_ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR"
|
|
185
185
|
export LOKI_ORIGINAL_PROJECT_DIR="$PROJECT_DIR"
|
|
@@ -471,6 +471,47 @@ parse_yaml_with_yq() {
|
|
|
471
471
|
# Load config file before setting defaults
|
|
472
472
|
load_config_file
|
|
473
473
|
|
|
474
|
+
# Load JSON settings from loki config set (v6.0.0)
|
|
475
|
+
_load_json_settings() {
|
|
476
|
+
local settings_file="${TARGET_DIR:-.}/.loki/config/settings.json"
|
|
477
|
+
[ -f "$settings_file" ] || return 0
|
|
478
|
+
eval "$(_LOKI_SETTINGS_FILE="$settings_file" python3 -c "
|
|
479
|
+
import json, sys, os, shlex
|
|
480
|
+
|
|
481
|
+
def get_nested(d, key):
|
|
482
|
+
\"\"\"Resolve dotted keys through nested dicts (model.planning -> data['model']['planning'])\"\"\"
|
|
483
|
+
parts = key.split('.')
|
|
484
|
+
cur = d
|
|
485
|
+
for p in parts:
|
|
486
|
+
if isinstance(cur, dict):
|
|
487
|
+
cur = cur.get(p)
|
|
488
|
+
else:
|
|
489
|
+
return None
|
|
490
|
+
return cur
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
with open(os.environ['_LOKI_SETTINGS_FILE']) as f:
|
|
494
|
+
data = json.load(f)
|
|
495
|
+
except Exception:
|
|
496
|
+
sys.exit(0)
|
|
497
|
+
mapping = {
|
|
498
|
+
'maxTier': 'LOKI_MAX_TIER',
|
|
499
|
+
'model.planning': 'LOKI_MODEL_PLANNING',
|
|
500
|
+
'model.development': 'LOKI_MODEL_DEVELOPMENT',
|
|
501
|
+
'model.fast': 'LOKI_MODEL_FAST',
|
|
502
|
+
'notify.slack': 'LOKI_SLACK_WEBHOOK',
|
|
503
|
+
'notify.discord': 'LOKI_DISCORD_WEBHOOK',
|
|
504
|
+
}
|
|
505
|
+
for key, env_var in mapping.items():
|
|
506
|
+
# Try nested dict lookup first, then flat key, then underscore variant
|
|
507
|
+
val = get_nested(data, key) or data.get(key) or data.get(key.replace('.', '_'))
|
|
508
|
+
if val and isinstance(val, str):
|
|
509
|
+
safe_val = shlex.quote(val)
|
|
510
|
+
print(f'[ -z \"\${{{env_var}:-}}\" ] && export {env_var}={safe_val}')
|
|
511
|
+
" 2>/dev/null)" 2>/dev/null || true
|
|
512
|
+
}
|
|
513
|
+
_LOKI_SETTINGS_FILE="${TARGET_DIR:-.}/.loki/config/settings.json" _load_json_settings
|
|
514
|
+
|
|
474
515
|
# Configuration
|
|
475
516
|
MAX_RETRIES=${LOKI_MAX_RETRIES:-50}
|
|
476
517
|
BASE_WAIT=${LOKI_BASE_WAIT:-60}
|
|
@@ -1305,10 +1346,25 @@ get_rarv_phase_name() {
|
|
|
1305
1346
|
}
|
|
1306
1347
|
|
|
1307
1348
|
# Get provider-specific tier parameter based on current tier
|
|
1308
|
-
#
|
|
1349
|
+
# v6.0.0: Delegates to resolve_model_for_tier() if available (dynamic resolution).
|
|
1350
|
+
# Falls back to static mapping for backward compatibility.
|
|
1309
1351
|
get_provider_tier_param() {
|
|
1310
1352
|
local tier="${1:-$CURRENT_TIER}"
|
|
1311
1353
|
|
|
1354
|
+
# v6.0.0: Use dynamic resolution if provider has resolve_model_for_tier
|
|
1355
|
+
if type resolve_model_for_tier &>/dev/null; then
|
|
1356
|
+
local resolved
|
|
1357
|
+
resolved=$(resolve_model_for_tier "$tier")
|
|
1358
|
+
# For Claude, extract short name (opus/sonnet/haiku) from full model ID
|
|
1359
|
+
if [ "${PROVIDER_NAME:-claude}" = "claude" ]; then
|
|
1360
|
+
echo "$resolved" | sed 's/claude-\([a-z]*\).*/\1/'
|
|
1361
|
+
else
|
|
1362
|
+
echo "$resolved"
|
|
1363
|
+
fi
|
|
1364
|
+
return
|
|
1365
|
+
fi
|
|
1366
|
+
|
|
1367
|
+
# Legacy fallback: static tier mapping
|
|
1312
1368
|
case "${PROVIDER_NAME:-claude}" in
|
|
1313
1369
|
claude)
|
|
1314
1370
|
case "$tier" in
|
|
@@ -1340,6 +1396,57 @@ get_provider_tier_param() {
|
|
|
1340
1396
|
esac
|
|
1341
1397
|
}
|
|
1342
1398
|
|
|
1399
|
+
#===============================================================================
|
|
1400
|
+
# Provider Spawn Timeout (v6.0.0)
|
|
1401
|
+
# Wraps provider invocation with timeout + retries.
|
|
1402
|
+
# Default: 120s timeout, 2 retries.
|
|
1403
|
+
#===============================================================================
|
|
1404
|
+
|
|
1405
|
+
PROVIDER_SPAWN_TIMEOUT=${LOKI_SPAWN_TIMEOUT:-120}
|
|
1406
|
+
PROVIDER_SPAWN_RETRIES=${LOKI_SPAWN_RETRIES:-2}
|
|
1407
|
+
|
|
1408
|
+
# Invoke a command with timeout and retry logic
|
|
1409
|
+
# Usage: invoke_with_timeout <timeout_seconds> <retries> <command...>
|
|
1410
|
+
invoke_with_timeout() {
|
|
1411
|
+
local timeout="$1"
|
|
1412
|
+
local max_retries="$2"
|
|
1413
|
+
shift 2
|
|
1414
|
+
|
|
1415
|
+
local attempt=0
|
|
1416
|
+
while [ $attempt -le $max_retries ]; do
|
|
1417
|
+
if [ $attempt -gt 0 ]; then
|
|
1418
|
+
log_warn "Provider spawn retry $attempt/$max_retries..."
|
|
1419
|
+
fi
|
|
1420
|
+
|
|
1421
|
+
local exit_code=0
|
|
1422
|
+
# Use timeout command if available (GNU coreutils or macOS)
|
|
1423
|
+
if command -v timeout &>/dev/null; then
|
|
1424
|
+
timeout "$timeout" "$@"
|
|
1425
|
+
exit_code=$?
|
|
1426
|
+
elif command -v gtimeout &>/dev/null; then
|
|
1427
|
+
gtimeout "$timeout" "$@"
|
|
1428
|
+
exit_code=$?
|
|
1429
|
+
else
|
|
1430
|
+
# Fallback: no timeout wrapper, run directly
|
|
1431
|
+
log_warn "timeout/gtimeout not available - running without timeout enforcement"
|
|
1432
|
+
"$@"
|
|
1433
|
+
exit_code=$?
|
|
1434
|
+
fi
|
|
1435
|
+
|
|
1436
|
+
# Exit code 124 = timeout
|
|
1437
|
+
if [ $exit_code -eq 124 ]; then
|
|
1438
|
+
log_warn "Provider spawn timed out after ${timeout}s (attempt $((attempt+1))/$((max_retries+1)))"
|
|
1439
|
+
((attempt++))
|
|
1440
|
+
continue
|
|
1441
|
+
fi
|
|
1442
|
+
|
|
1443
|
+
return $exit_code
|
|
1444
|
+
done
|
|
1445
|
+
|
|
1446
|
+
log_error "Provider spawn failed after $((max_retries+1)) attempts (timeout=${timeout}s)"
|
|
1447
|
+
return 124
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1343
1450
|
#===============================================================================
|
|
1344
1451
|
# GitHub Integration Functions (v4.1.0)
|
|
1345
1452
|
#===============================================================================
|
|
@@ -5125,6 +5232,141 @@ AGG_SCRIPT
|
|
|
5125
5232
|
return 0
|
|
5126
5233
|
}
|
|
5127
5234
|
|
|
5235
|
+
#===============================================================================
|
|
5236
|
+
# Adversarial Testing (v6.0.0) - For Standard+ complexity tiers
|
|
5237
|
+
# Spawns an adversarial agent that tries to break the implementation.
|
|
5238
|
+
# Only runs when complexity >= standard (6+ agents).
|
|
5239
|
+
#===============================================================================
|
|
5240
|
+
|
|
5241
|
+
run_adversarial_testing() {
|
|
5242
|
+
local loki_dir="${TARGET_DIR:-.}/.loki"
|
|
5243
|
+
local adversarial_dir="$loki_dir/quality/adversarial"
|
|
5244
|
+
local test_id
|
|
5245
|
+
test_id="adversarial-$(date -u +%Y%m%dT%H%M%SZ)-${ITERATION_COUNT:-0}"
|
|
5246
|
+
mkdir -p "$adversarial_dir/$test_id"
|
|
5247
|
+
|
|
5248
|
+
# Only run for Standard+ complexity
|
|
5249
|
+
local complexity="${LOKI_COMPLEXITY:-auto}"
|
|
5250
|
+
if [ "$complexity" = "simple" ]; then
|
|
5251
|
+
log_debug "Adversarial testing skipped: simple complexity tier"
|
|
5252
|
+
return 0
|
|
5253
|
+
fi
|
|
5254
|
+
|
|
5255
|
+
# Check if adversarial testing is disabled
|
|
5256
|
+
if [ "${LOKI_ADVERSARIAL_TESTING:-true}" = "false" ]; then
|
|
5257
|
+
log_debug "Adversarial testing disabled via LOKI_ADVERSARIAL_TESTING=false"
|
|
5258
|
+
return 0
|
|
5259
|
+
fi
|
|
5260
|
+
|
|
5261
|
+
log_header "ADVERSARIAL TESTING: $test_id"
|
|
5262
|
+
|
|
5263
|
+
# Get diff for adversarial analysis
|
|
5264
|
+
local diff_content
|
|
5265
|
+
diff_content=$(git -C "${TARGET_DIR:-.}" diff HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --cached 2>/dev/null || echo "")
|
|
5266
|
+
if [ -z "$diff_content" ]; then
|
|
5267
|
+
log_info "Adversarial testing: No diff to test, skipping"
|
|
5268
|
+
return 0
|
|
5269
|
+
fi
|
|
5270
|
+
|
|
5271
|
+
local changed_files
|
|
5272
|
+
changed_files=$(git -C "${TARGET_DIR:-.}" diff --name-only HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --name-only --cached 2>/dev/null || echo "")
|
|
5273
|
+
|
|
5274
|
+
# Write analysis files
|
|
5275
|
+
local diff_file="$adversarial_dir/$test_id/diff.txt"
|
|
5276
|
+
local files_file="$adversarial_dir/$test_id/files.txt"
|
|
5277
|
+
echo "$diff_content" > "$diff_file"
|
|
5278
|
+
echo "$changed_files" > "$files_file"
|
|
5279
|
+
|
|
5280
|
+
# Build adversarial prompt
|
|
5281
|
+
local adversarial_prompt="You are an ADVERSARIAL TESTER. Your goal is to BREAK the implementation.
|
|
5282
|
+
|
|
5283
|
+
CHANGED FILES:
|
|
5284
|
+
$(cat "$files_file")
|
|
5285
|
+
|
|
5286
|
+
DIFF:
|
|
5287
|
+
$(head -500 "$diff_file")
|
|
5288
|
+
|
|
5289
|
+
YOUR MISSION:
|
|
5290
|
+
1. Find edge cases that will cause crashes or incorrect behavior
|
|
5291
|
+
2. Identify inputs that bypass validation
|
|
5292
|
+
3. Find race conditions or concurrency issues
|
|
5293
|
+
4. Discover security vulnerabilities (injection, auth bypass, SSRF)
|
|
5294
|
+
5. Find resource exhaustion vectors (unbounded loops, memory leaks)
|
|
5295
|
+
6. Identify error handling gaps (missing try/catch, unchecked returns)
|
|
5296
|
+
|
|
5297
|
+
OUTPUT FORMAT (STRICT):
|
|
5298
|
+
ATTACK_VECTORS:
|
|
5299
|
+
- [severity] [category] description | reproduction steps
|
|
5300
|
+
Severity: Critical, High, Medium, Low
|
|
5301
|
+
Category: crash, security, correctness, performance, resource
|
|
5302
|
+
|
|
5303
|
+
SUGGESTED_TESTS:
|
|
5304
|
+
- Test description that would catch this issue
|
|
5305
|
+
|
|
5306
|
+
OVERALL_RISK: HIGH or MEDIUM or LOW"
|
|
5307
|
+
|
|
5308
|
+
local result_file="$adversarial_dir/$test_id/result.txt"
|
|
5309
|
+
|
|
5310
|
+
# Run adversarial agent
|
|
5311
|
+
log_info "Spawning adversarial agent..."
|
|
5312
|
+
case "${PROVIDER_NAME:-claude}" in
|
|
5313
|
+
claude)
|
|
5314
|
+
if command -v claude &>/dev/null; then
|
|
5315
|
+
claude --dangerously-skip-permissions -p "$adversarial_prompt" \
|
|
5316
|
+
--output-format text > "$result_file" 2>/dev/null || true
|
|
5317
|
+
fi
|
|
5318
|
+
;;
|
|
5319
|
+
codex)
|
|
5320
|
+
if command -v codex &>/dev/null; then
|
|
5321
|
+
codex exec --full-auto "$adversarial_prompt" \
|
|
5322
|
+
> "$result_file" 2>/dev/null || true
|
|
5323
|
+
fi
|
|
5324
|
+
;;
|
|
5325
|
+
gemini)
|
|
5326
|
+
if command -v gemini &>/dev/null; then
|
|
5327
|
+
invoke_gemini_capture "$adversarial_prompt" \
|
|
5328
|
+
> "$result_file" 2>/dev/null || true
|
|
5329
|
+
fi
|
|
5330
|
+
;;
|
|
5331
|
+
*)
|
|
5332
|
+
echo "ATTACK_VECTORS: None (unknown provider)" > "$result_file"
|
|
5333
|
+
echo "OVERALL_RISK: LOW" >> "$result_file"
|
|
5334
|
+
;;
|
|
5335
|
+
esac
|
|
5336
|
+
|
|
5337
|
+
if [ ! -s "$result_file" ]; then
|
|
5338
|
+
log_warn "Adversarial agent produced no output"
|
|
5339
|
+
return 0
|
|
5340
|
+
fi
|
|
5341
|
+
|
|
5342
|
+
# Parse risk level
|
|
5343
|
+
local risk_level
|
|
5344
|
+
risk_level=$(grep -i "OVERALL_RISK:" "$result_file" | head -1 | sed 's/.*OVERALL_RISK:[[:space:]]*//' | awk '{print toupper($1)}')
|
|
5345
|
+
|
|
5346
|
+
# Count critical/high attack vectors
|
|
5347
|
+
local critical_count high_count
|
|
5348
|
+
critical_count=$(grep -ci "\[critical\]" "$result_file" 2>/dev/null || echo "0")
|
|
5349
|
+
high_count=$(grep -ci "\[high\]" "$result_file" 2>/dev/null || echo "0")
|
|
5350
|
+
|
|
5351
|
+
log_info "Adversarial testing complete: risk=$risk_level, critical=$critical_count, high=$high_count"
|
|
5352
|
+
|
|
5353
|
+
emit_event_json "adversarial_test_complete" \
|
|
5354
|
+
"test_id=$test_id" \
|
|
5355
|
+
"risk_level=${risk_level:-UNKNOWN}" \
|
|
5356
|
+
"critical_count=$critical_count" \
|
|
5357
|
+
"high_count=$high_count" \
|
|
5358
|
+
"iteration=$ITERATION_COUNT"
|
|
5359
|
+
|
|
5360
|
+
# Block on critical findings
|
|
5361
|
+
if [ "$critical_count" -gt 0 ]; then
|
|
5362
|
+
log_error "ADVERSARIAL TEST BLOCKED: $critical_count critical attack vectors found"
|
|
5363
|
+
log_error "Details: $adversarial_dir/$test_id/result.txt"
|
|
5364
|
+
return 1
|
|
5365
|
+
fi
|
|
5366
|
+
|
|
5367
|
+
return 0
|
|
5368
|
+
}
|
|
5369
|
+
|
|
5128
5370
|
load_solutions_context() {
|
|
5129
5371
|
# Load relevant structured solutions for the current task context
|
|
5130
5372
|
local context="$1"
|
|
@@ -6444,6 +6686,81 @@ except Exception as e:
|
|
|
6444
6686
|
PYEOF
|
|
6445
6687
|
}
|
|
6446
6688
|
|
|
6689
|
+
#===============================================================================
|
|
6690
|
+
# Knowledge Graph Integration (v6.0.0)
|
|
6691
|
+
# Enrich prompts with cross-project patterns and store new learnings.
|
|
6692
|
+
#===============================================================================
|
|
6693
|
+
|
|
6694
|
+
# Enrich prompt context with relevant cross-project patterns
|
|
6695
|
+
enrich_from_knowledge_graph() {
|
|
6696
|
+
local context="$1"
|
|
6697
|
+
local max_patterns="${2:-5}"
|
|
6698
|
+
|
|
6699
|
+
_LOKI_KG_CONTEXT="$context" _LOKI_KG_MAX="$max_patterns" \
|
|
6700
|
+
_LOKI_PROJECT_DIR="$PROJECT_DIR" \
|
|
6701
|
+
python3 << 'PYEOF' 2>/dev/null || echo ""
|
|
6702
|
+
import sys
|
|
6703
|
+
import os
|
|
6704
|
+
import json
|
|
6705
|
+
|
|
6706
|
+
project_dir = os.environ.get('_LOKI_PROJECT_DIR', '')
|
|
6707
|
+
context = os.environ.get('_LOKI_KG_CONTEXT', '')
|
|
6708
|
+
max_results = int(os.environ.get('_LOKI_KG_MAX', '5'))
|
|
6709
|
+
|
|
6710
|
+
if not project_dir:
|
|
6711
|
+
sys.exit(0)
|
|
6712
|
+
sys.path.insert(0, project_dir)
|
|
6713
|
+
try:
|
|
6714
|
+
from memory.knowledge_graph import OrganizationKnowledgeGraph
|
|
6715
|
+
kg = OrganizationKnowledgeGraph()
|
|
6716
|
+
patterns = kg.query_patterns(context, max_results=max_results)
|
|
6717
|
+
if patterns:
|
|
6718
|
+
output = "\n## Cross-Project Knowledge (from knowledge graph)\n"
|
|
6719
|
+
for p in patterns:
|
|
6720
|
+
name = p.get('name', p.get('pattern', 'unnamed'))
|
|
6721
|
+
category = p.get('category', '')
|
|
6722
|
+
desc = p.get('description', '')
|
|
6723
|
+
output += f"- **{name}** ({category}): {desc}\n"
|
|
6724
|
+
print(output)
|
|
6725
|
+
except Exception:
|
|
6726
|
+
pass
|
|
6727
|
+
PYEOF
|
|
6728
|
+
}
|
|
6729
|
+
|
|
6730
|
+
# Store new patterns to the knowledge graph after successful iterations
|
|
6731
|
+
store_to_knowledge_graph() {
|
|
6732
|
+
local target_dir="${TARGET_DIR:-.}"
|
|
6733
|
+
|
|
6734
|
+
_LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \
|
|
6735
|
+
python3 << 'PYEOF' 2>/dev/null || true
|
|
6736
|
+
import sys
|
|
6737
|
+
import os
|
|
6738
|
+
|
|
6739
|
+
project_dir = os.environ.get('_LOKI_PROJECT_DIR', '')
|
|
6740
|
+
target_dir = os.environ.get('_LOKI_TARGET_DIR', '.')
|
|
6741
|
+
|
|
6742
|
+
sys.path.insert(0, project_dir)
|
|
6743
|
+
try:
|
|
6744
|
+
from memory.knowledge_graph import OrganizationKnowledgeGraph
|
|
6745
|
+
from pathlib import Path
|
|
6746
|
+
|
|
6747
|
+
kg = OrganizationKnowledgeGraph()
|
|
6748
|
+
project_dirs = [Path(target_dir)]
|
|
6749
|
+
|
|
6750
|
+
# Extract and store patterns
|
|
6751
|
+
patterns = kg.extract_patterns(project_dirs)
|
|
6752
|
+
if patterns:
|
|
6753
|
+
patterns = kg.deduplicate_patterns(patterns)
|
|
6754
|
+
kg.save_patterns(patterns)
|
|
6755
|
+
|
|
6756
|
+
# Rebuild graph
|
|
6757
|
+
kg.build_graph(project_dirs)
|
|
6758
|
+
kg.save_graph()
|
|
6759
|
+
except Exception:
|
|
6760
|
+
pass
|
|
6761
|
+
PYEOF
|
|
6762
|
+
}
|
|
6763
|
+
|
|
6447
6764
|
#===============================================================================
|
|
6448
6765
|
# Save/Load Wrapper State
|
|
6449
6766
|
#===============================================================================
|
package/dashboard/__init__.py
CHANGED
|
@@ -126,6 +126,10 @@ class MigrationManifest:
|
|
|
126
126
|
feature_list_path: str = ""
|
|
127
127
|
migration_plan_path: str = ""
|
|
128
128
|
checkpoints: list[str] = field(default_factory=list)
|
|
129
|
+
status: str = "pending"
|
|
130
|
+
progress_pct: int = 0
|
|
131
|
+
updated_at: str = ""
|
|
132
|
+
source_path: str = ""
|
|
129
133
|
|
|
130
134
|
|
|
131
135
|
# ---------------------------------------------------------------------------
|
|
@@ -149,7 +153,7 @@ def _atomic_write(path: Path, content: str) -> None:
|
|
|
149
153
|
os.fsync(fd)
|
|
150
154
|
finally:
|
|
151
155
|
os.close(fd)
|
|
152
|
-
os.
|
|
156
|
+
os.replace(tmp_path, str(path))
|
|
153
157
|
except OSError as exc:
|
|
154
158
|
logger.error("Failed to write %s: %s", path, exc)
|
|
155
159
|
# Clean up temp file on failure
|
|
@@ -312,20 +316,23 @@ class MigrationPipeline:
|
|
|
312
316
|
return phase_data.get("status", "pending")
|
|
313
317
|
|
|
314
318
|
def start_phase(self, phase: str) -> None:
|
|
315
|
-
"""Start a phase (transition
|
|
319
|
+
"""Start a phase (transition to in_progress).
|
|
320
|
+
|
|
321
|
+
Idempotent if already in_progress. Also allows restarting completed or
|
|
322
|
+
failed phases (e.g., when using --resume --phase <phase>).
|
|
323
|
+
"""
|
|
316
324
|
if phase not in PHASE_ORDER:
|
|
317
325
|
raise ValueError(f"Unknown phase: {phase}")
|
|
318
326
|
with self._lock:
|
|
319
327
|
manifest = self._load_manifest_unlocked()
|
|
320
|
-
|
|
328
|
+
if phase not in manifest.phases:
|
|
329
|
+
manifest.phases[phase] = {"status": "pending", "started_at": "", "completed_at": ""}
|
|
330
|
+
current_status = manifest.phases[phase].get("status", "pending")
|
|
321
331
|
if current_status == "in_progress":
|
|
322
332
|
return # Already started, idempotent
|
|
323
|
-
if current_status != "pending":
|
|
324
|
-
raise RuntimeError(
|
|
325
|
-
f"Cannot start phase '{phase}': status is '{current_status}', expected 'pending'"
|
|
326
|
-
)
|
|
327
333
|
manifest.phases[phase]["status"] = "in_progress"
|
|
328
334
|
manifest.phases[phase]["started_at"] = datetime.now(timezone.utc).isoformat()
|
|
335
|
+
manifest.phases[phase]["completed_at"] = ""
|
|
329
336
|
self._save_manifest_unlocked(manifest)
|
|
330
337
|
|
|
331
338
|
def _check_phase_gate_unlocked(self, from_phase: str, to_phase: str) -> tuple[bool, str]:
|
|
@@ -402,55 +409,13 @@ class MigrationPipeline:
|
|
|
402
409
|
def check_phase_gate(self, from_phase: str, to_phase: str) -> tuple[bool, str]:
|
|
403
410
|
"""Validate whether transition from from_phase to to_phase is allowed.
|
|
404
411
|
|
|
412
|
+
Thread-safe wrapper that delegates to _check_phase_gate_unlocked under lock.
|
|
413
|
+
|
|
405
414
|
Returns:
|
|
406
415
|
Tuple of (allowed, reason). If allowed is False, reason explains why.
|
|
407
416
|
"""
|
|
408
|
-
|
|
409
|
-
return
|
|
410
|
-
|
|
411
|
-
from_idx = PHASE_ORDER.index(from_phase)
|
|
412
|
-
to_idx = PHASE_ORDER.index(to_phase)
|
|
413
|
-
if to_idx != from_idx + 1:
|
|
414
|
-
return False, f"Cannot jump from {from_phase} to {to_phase}"
|
|
415
|
-
|
|
416
|
-
# Gate: understand -> guardrail
|
|
417
|
-
if from_phase == "understand" and to_phase == "guardrail":
|
|
418
|
-
docs_dir = self.migration_dir / "docs"
|
|
419
|
-
has_docs = any(docs_dir.iterdir()) if docs_dir.exists() else False
|
|
420
|
-
if not has_docs:
|
|
421
|
-
return False, "Phase gate failed: no documentation generated in docs/"
|
|
422
|
-
seams_path = self.migration_dir / "seams.json"
|
|
423
|
-
if not seams_path.exists():
|
|
424
|
-
return False, "Phase gate failed: seams.json does not exist"
|
|
425
|
-
return True, "Gate passed: docs generated and seams.json exists"
|
|
426
|
-
|
|
427
|
-
# Gate: guardrail -> migrate
|
|
428
|
-
if from_phase == "guardrail" and to_phase == "migrate":
|
|
429
|
-
try:
|
|
430
|
-
features = self.load_features()
|
|
431
|
-
except FileNotFoundError:
|
|
432
|
-
return False, "Phase gate failed: features.json not found"
|
|
433
|
-
if not features:
|
|
434
|
-
return False, "No features defined"
|
|
435
|
-
failing = [f for f in features if not f.passes]
|
|
436
|
-
if failing:
|
|
437
|
-
ids = ", ".join(f.id for f in failing[:5])
|
|
438
|
-
return False, f"Phase gate failed: {len(failing)} characterization tests not passing ({ids})"
|
|
439
|
-
return True, "Gate passed: all characterization tests pass"
|
|
440
|
-
|
|
441
|
-
# Gate: migrate -> verify
|
|
442
|
-
if from_phase == "migrate" and to_phase == "verify":
|
|
443
|
-
try:
|
|
444
|
-
plan = self.load_plan()
|
|
445
|
-
except FileNotFoundError:
|
|
446
|
-
return False, "Phase gate failed: migration-plan.json not found"
|
|
447
|
-
incomplete = [s for s in plan.steps if s.status != "completed"]
|
|
448
|
-
if incomplete:
|
|
449
|
-
ids = ", ".join(s.id for s in incomplete[:5])
|
|
450
|
-
return False, f"Phase gate failed: {len(incomplete)} steps not completed ({ids})"
|
|
451
|
-
return True, "Gate passed: all migration steps completed"
|
|
452
|
-
|
|
453
|
-
return True, "Gate passed"
|
|
417
|
+
with self._lock:
|
|
418
|
+
return self._check_phase_gate_unlocked(from_phase, to_phase)
|
|
454
419
|
|
|
455
420
|
def advance_phase(self, phase: str) -> PhaseResult:
|
|
456
421
|
"""Mark the current phase as complete and start the next one.
|
|
@@ -492,6 +457,8 @@ class MigrationPipeline:
|
|
|
492
457
|
|
|
493
458
|
# Start next phase if there is one
|
|
494
459
|
if next_phase is not None:
|
|
460
|
+
if next_phase not in manifest.phases:
|
|
461
|
+
manifest.phases[next_phase] = {"status": "pending", "started_at": "", "completed_at": ""}
|
|
495
462
|
manifest.phases[next_phase]["status"] = "in_progress"
|
|
496
463
|
manifest.phases[next_phase]["started_at"] = now
|
|
497
464
|
|
|
@@ -698,14 +665,16 @@ class MigrationPipeline:
|
|
|
698
665
|
completed_phases: list[str] = []
|
|
699
666
|
for phase in PHASE_ORDER:
|
|
700
667
|
status = manifest.phases.get(phase, {}).get("status", "pending")
|
|
701
|
-
if status == "
|
|
668
|
+
if status == "completed":
|
|
669
|
+
completed_phases.append(phase)
|
|
670
|
+
current_phase = phase
|
|
671
|
+
overall_status = "in_progress" # partial completion
|
|
672
|
+
elif status == "in_progress":
|
|
702
673
|
current_phase = phase
|
|
703
674
|
overall_status = "in_progress"
|
|
704
|
-
|
|
705
|
-
if status == "completed":
|
|
675
|
+
elif status == "failed":
|
|
706
676
|
current_phase = phase
|
|
707
|
-
|
|
708
|
-
overall_status = "completed"
|
|
677
|
+
overall_status = "failed"
|
|
709
678
|
|
|
710
679
|
# Feature stats
|
|
711
680
|
features_total = 0
|
|
@@ -754,19 +723,47 @@ class MigrationPipeline:
|
|
|
754
723
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
755
724
|
last_checkpoint_data = {"tag": last_tag, "step_id": "", "timestamp": ""}
|
|
756
725
|
|
|
726
|
+
# Check if all phases are completed
|
|
727
|
+
if len(completed_phases) == len(PHASE_ORDER):
|
|
728
|
+
overall_status = "completed"
|
|
729
|
+
|
|
730
|
+
# Seam stats
|
|
731
|
+
seams_data: Optional[dict[str, Any]] = None
|
|
732
|
+
try:
|
|
733
|
+
seams = self.load_seams()
|
|
734
|
+
seams_high = sum(1 for s in seams if getattr(s, "priority", "medium") == "high")
|
|
735
|
+
seams_medium = sum(1 for s in seams if getattr(s, "priority", "medium") == "medium")
|
|
736
|
+
seams_low = sum(1 for s in seams if getattr(s, "priority", "medium") == "low")
|
|
737
|
+
seams_data = {"total": len(seams), "high": seams_high, "medium": seams_medium, "low": seams_low}
|
|
738
|
+
except (FileNotFoundError, json.JSONDecodeError, TypeError):
|
|
739
|
+
pass
|
|
740
|
+
|
|
741
|
+
# Flatten source/target to strings for UI consumption
|
|
742
|
+
source_path = ""
|
|
743
|
+
target_name = ""
|
|
744
|
+
if isinstance(manifest.source_info, dict):
|
|
745
|
+
source_path = manifest.source_info.get("path", "")
|
|
746
|
+
elif isinstance(manifest.source_info, str):
|
|
747
|
+
source_path = manifest.source_info
|
|
748
|
+
if isinstance(manifest.target_info, dict):
|
|
749
|
+
target_name = manifest.target_info.get("target", "")
|
|
750
|
+
elif isinstance(manifest.target_info, str):
|
|
751
|
+
target_name = manifest.target_info
|
|
752
|
+
|
|
757
753
|
return {
|
|
758
754
|
"migration_id": self.migration_id,
|
|
759
755
|
"status": overall_status,
|
|
760
756
|
"current_phase": current_phase,
|
|
761
757
|
"phases": manifest.phases,
|
|
762
758
|
"completed_phases": completed_phases,
|
|
763
|
-
"source":
|
|
764
|
-
"target":
|
|
759
|
+
"source": source_path,
|
|
760
|
+
"target": target_name,
|
|
765
761
|
"current_step": current_step,
|
|
766
762
|
"features": {"passing": features_passing, "total": features_total},
|
|
767
763
|
"steps": {"current": current_step_index, "completed": steps_completed, "total": steps_total},
|
|
768
764
|
"last_checkpoint": last_checkpoint_data,
|
|
769
765
|
"checkpoints_count": len(manifest.checkpoints),
|
|
766
|
+
"seams": seams_data,
|
|
770
767
|
}
|
|
771
768
|
|
|
772
769
|
def generate_plan_summary(self) -> str:
|
|
@@ -893,21 +890,34 @@ def list_migrations() -> list[dict[str, Any]]:
|
|
|
893
890
|
# Determine overall status from phases (clean string, no parenthesized phase)
|
|
894
891
|
phases = data.get("phases", {})
|
|
895
892
|
status = "pending"
|
|
893
|
+
all_completed = True
|
|
896
894
|
for phase in PHASE_ORDER:
|
|
897
895
|
phase_status = phases.get(phase, {}).get("status", "pending")
|
|
896
|
+
if phase_status == "failed":
|
|
897
|
+
status = "failed"
|
|
898
|
+
all_completed = False
|
|
899
|
+
break
|
|
898
900
|
if phase_status == "in_progress":
|
|
899
901
|
status = "in_progress"
|
|
902
|
+
all_completed = False
|
|
900
903
|
break
|
|
901
904
|
if phase_status == "completed":
|
|
902
|
-
status = "
|
|
905
|
+
status = "in_progress" # partial completion
|
|
906
|
+
else:
|
|
907
|
+
all_completed = False
|
|
908
|
+
if all_completed:
|
|
909
|
+
status = "completed"
|
|
903
910
|
|
|
904
911
|
source_info = data.get("source_info", {})
|
|
912
|
+
source_path = source_info.get("path", "") if isinstance(source_info, dict) else str(source_info)
|
|
913
|
+
target_info = data.get("target_info", {})
|
|
914
|
+
target_name = target_info.get("target", "") if isinstance(target_info, dict) else str(target_info)
|
|
905
915
|
results.append({
|
|
906
916
|
"id": data.get("id", entry.name),
|
|
907
917
|
"created_at": data.get("created_at", ""),
|
|
908
|
-
"source":
|
|
909
|
-
"source_path":
|
|
910
|
-
"target":
|
|
918
|
+
"source": source_path,
|
|
919
|
+
"source_path": source_path,
|
|
920
|
+
"target": target_name,
|
|
911
921
|
"status": status,
|
|
912
922
|
})
|
|
913
923
|
except (json.JSONDecodeError, OSError) as exc:
|
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED