loki-mode 7.77.0 → 7.79.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/autonomy/run.sh CHANGED
@@ -40,7 +40,18 @@
40
40
  # LOKI_AUDIT_DISABLED - Disable audit logging (default: false)
41
41
  # LOKI_MAX_PARALLEL_AGENTS - Limit concurrent agent spawning (default: 10)
42
42
  # LOKI_SANDBOX_MODE - Run in sandboxed container (default: false, requires Docker)
43
- # LOKI_ALLOWED_PATHS - Comma-separated paths agents can modify (default: all)
43
+ # LOKI_ALLOWED_PATHS - Comma-separated path allowlist. PARTIAL enforcement
44
+ # (honest scope): when set, run.sh rejects a sandbox
45
+ # custom --mount whose host path is outside the
46
+ # allowlist (a write surface run.sh itself controls).
47
+ # It does NOT and CANNOT restrict provider-driven
48
+ # agent file writes: claude/codex/cline/aider write
49
+ # files directly, not through run.sh, so run.sh never
50
+ # sees those writes. The containment for agent writes
51
+ # is the OS user + the LOKI_SANDBOX_MODE container
52
+ # (cap-drop, seccomp, read-only mounts), not this var.
53
+ # Treat ALLOWED_PATHS as defense-in-depth on the
54
+ # mount surface, not a complete write sandbox.
44
55
  # LOKI_BLOCKED_COMMANDS - Comma-separated blocked shell commands (default: rm -rf /)
45
56
  #
46
57
  # OIDC / SSO Authentication (optional, works alongside token auth):
@@ -225,6 +236,17 @@ fi
225
236
  # Configuration File Support (v4.1.0)
226
237
  # Loads settings from config file, environment variables take precedence
227
238
  #===============================================================================
239
+ # #691: source the canonical config mapping + shared per-key export helper. This
240
+ # is the SAME lib the loki CLI pre-pass uses, so the YAML/config-file mapping
241
+ # cannot drift between the CLI and the runner. Side-effect-free on source. The
242
+ # parsers below delegate to loki_config_export_key with override=0, preserving
243
+ # the shipped env-wins auto-discovery contract byte-for-byte.
244
+ _LOKI_CONFIG_MAP_LIB="$SCRIPT_DIR/lib/config-map.sh"
245
+ if [ -f "$_LOKI_CONFIG_MAP_LIB" ]; then
246
+ # shellcheck source=lib/config-map.sh
247
+ source "$_LOKI_CONFIG_MAP_LIB"
248
+ fi
249
+
228
250
  load_config_file() {
229
251
  local config_file=""
230
252
 
@@ -258,95 +280,22 @@ load_config_file() {
258
280
  parse_yaml_with_yq "$config_file"
259
281
  }
260
282
 
261
- # Fallback YAML parser for simple key: value format
283
+ # Fallback YAML parser for simple key: value format.
284
+ # #691: iterates the canonical LOKI_CONFIG_MAP (single source of truth) instead
285
+ # of 64 hand-maintained set_from_yaml calls. override=0 -> ambient env still
286
+ # wins (auto-discovery contract unchanged). Falls back to nothing if the lib is
287
+ # absent (defensive; the lib ships alongside run.sh).
262
288
  parse_simple_yaml() {
263
289
  local file="$1"
264
-
265
- # Parse core settings
266
- set_from_yaml "$file" "core.max_retries" "LOKI_MAX_RETRIES"
267
- set_from_yaml "$file" "core.base_wait" "LOKI_BASE_WAIT"
268
- set_from_yaml "$file" "core.max_wait" "LOKI_MAX_WAIT"
269
- set_from_yaml "$file" "core.skip_prereqs" "LOKI_SKIP_PREREQS"
270
-
271
- # Dashboard
272
- set_from_yaml "$file" "dashboard.enabled" "LOKI_DASHBOARD"
273
- set_from_yaml "$file" "dashboard.port" "LOKI_DASHBOARD_PORT"
274
-
275
- # Resources
276
- set_from_yaml "$file" "resources.check_interval" "LOKI_RESOURCE_CHECK_INTERVAL"
277
- set_from_yaml "$file" "resources.cpu_threshold" "LOKI_RESOURCE_CPU_THRESHOLD"
278
- set_from_yaml "$file" "resources.mem_threshold" "LOKI_RESOURCE_MEM_THRESHOLD"
279
-
280
- # Security
281
- set_from_yaml "$file" "security.staged_autonomy" "LOKI_STAGED_AUTONOMY"
282
- set_from_yaml "$file" "security.audit_log" "LOKI_AUDIT_LOG"
283
- set_from_yaml "$file" "security.max_parallel_agents" "LOKI_MAX_PARALLEL_AGENTS"
284
- set_from_yaml "$file" "security.sandbox_mode" "LOKI_SANDBOX_MODE"
285
- set_from_yaml "$file" "security.allowed_paths" "LOKI_ALLOWED_PATHS"
286
- set_from_yaml "$file" "security.blocked_commands" "LOKI_BLOCKED_COMMANDS"
287
-
288
- # Phases
289
- set_from_yaml "$file" "phases.unit_tests" "LOKI_PHASE_UNIT_TESTS"
290
- set_from_yaml "$file" "phases.api_tests" "LOKI_PHASE_API_TESTS"
291
- set_from_yaml "$file" "phases.e2e_tests" "LOKI_PHASE_E2E_TESTS"
292
- set_from_yaml "$file" "phases.security" "LOKI_PHASE_SECURITY"
293
- set_from_yaml "$file" "phases.integration" "LOKI_PHASE_INTEGRATION"
294
- set_from_yaml "$file" "phases.code_review" "LOKI_PHASE_CODE_REVIEW"
295
- set_from_yaml "$file" "phases.web_research" "LOKI_PHASE_WEB_RESEARCH"
296
- set_from_yaml "$file" "phases.performance" "LOKI_PHASE_PERFORMANCE"
297
- set_from_yaml "$file" "phases.accessibility" "LOKI_PHASE_ACCESSIBILITY"
298
- set_from_yaml "$file" "phases.regression" "LOKI_PHASE_REGRESSION"
299
- set_from_yaml "$file" "phases.uat" "LOKI_PHASE_UAT"
300
-
301
- # Completion
302
- set_from_yaml "$file" "completion.promise" "LOKI_COMPLETION_PROMISE"
303
- set_from_yaml "$file" "completion.max_iterations" "LOKI_MAX_ITERATIONS"
304
- set_from_yaml "$file" "completion.perpetual_mode" "LOKI_PERPETUAL_MODE"
305
- set_from_yaml "$file" "completion.council.enabled" "LOKI_COUNCIL_ENABLED"
306
- set_from_yaml "$file" "completion.council.size" "LOKI_COUNCIL_SIZE"
307
- set_from_yaml "$file" "completion.council.threshold" "LOKI_COUNCIL_THRESHOLD"
308
- set_from_yaml "$file" "completion.council.check_interval" "LOKI_COUNCIL_CHECK_INTERVAL"
309
- set_from_yaml "$file" "completion.council.min_iterations" "LOKI_COUNCIL_MIN_ITERATIONS"
310
- set_from_yaml "$file" "completion.council.stagnation_limit" "LOKI_COUNCIL_STAGNATION_LIMIT"
311
- set_from_yaml "$file" "completion.uncertainty.escalation" "LOKI_UNCERTAINTY_ESCALATION"
312
- set_from_yaml "$file" "completion.uncertainty.rounds" "LOKI_UNCERTAINTY_ROUNDS"
313
- set_from_yaml "$file" "completion.uncertainty.nochange_min" "LOKI_UNCERTAINTY_NOCHANGE_MIN"
314
- set_from_yaml "$file" "completion.uncertainty.split_rounds" "LOKI_UNCERTAINTY_SPLIT_ROUNDS"
315
-
316
- # Model
317
- set_from_yaml "$file" "model.prompt_repetition" "LOKI_PROMPT_REPETITION"
318
- set_from_yaml "$file" "model.confidence_routing" "LOKI_CONFIDENCE_ROUTING"
319
- set_from_yaml "$file" "model.autonomy_mode" "LOKI_AUTONOMY_MODE"
320
- set_from_yaml "$file" "model.planning" "LOKI_MODEL_PLANNING"
321
- set_from_yaml "$file" "model.development" "LOKI_MODEL_DEVELOPMENT"
322
- set_from_yaml "$file" "model.fast" "LOKI_MODEL_FAST"
323
-
324
- # Parallel
325
- set_from_yaml "$file" "parallel.enabled" "LOKI_PARALLEL_MODE"
326
- set_from_yaml "$file" "parallel.max_worktrees" "LOKI_MAX_WORKTREES"
327
- set_from_yaml "$file" "parallel.max_sessions" "LOKI_MAX_PARALLEL_SESSIONS"
328
- set_from_yaml "$file" "parallel.testing" "LOKI_PARALLEL_TESTING"
329
- set_from_yaml "$file" "parallel.docs" "LOKI_PARALLEL_DOCS"
330
- set_from_yaml "$file" "parallel.blog" "LOKI_PARALLEL_BLOG"
331
- set_from_yaml "$file" "parallel.auto_merge" "LOKI_AUTO_MERGE"
332
-
333
- # Complexity
334
- set_from_yaml "$file" "complexity.tier" "LOKI_COMPLEXITY"
335
-
336
- # GitHub
337
- set_from_yaml "$file" "github.import" "LOKI_GITHUB_IMPORT"
338
- set_from_yaml "$file" "github.pr" "LOKI_GITHUB_PR"
339
- set_from_yaml "$file" "github.sync" "LOKI_GITHUB_SYNC"
340
- set_from_yaml "$file" "github.repo" "LOKI_GITHUB_REPO"
341
- set_from_yaml "$file" "github.labels" "LOKI_GITHUB_LABELS"
342
- set_from_yaml "$file" "github.milestone" "LOKI_GITHUB_MILESTONE"
343
- set_from_yaml "$file" "github.assignee" "LOKI_GITHUB_ASSIGNEE"
344
- set_from_yaml "$file" "github.limit" "LOKI_GITHUB_LIMIT"
345
- set_from_yaml "$file" "github.pr_label" "LOKI_GITHUB_PR_LABEL"
346
-
347
- # Notifications
348
- set_from_yaml "$file" "notifications.enabled" "LOKI_NOTIFICATIONS"
349
- set_from_yaml "$file" "notifications.sound" "LOKI_NOTIFICATION_SOUND"
290
+ if ! declare -p LOKI_CONFIG_MAP >/dev/null 2>&1; then
291
+ return 0
292
+ fi
293
+ local mapping yaml_path env_var
294
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
295
+ yaml_path="${mapping%%:*}"
296
+ env_var="${mapping##*:}"
297
+ set_from_yaml "$file" "$yaml_path" "$env_var"
298
+ done
350
299
  }
351
300
 
352
301
  # Validate YAML value to prevent injection attacks
@@ -359,11 +308,19 @@ validate_yaml_value() {
359
308
  return 1
360
309
  fi
361
310
 
362
- # Reject values with dangerous shell metacharacters
363
- # Allow alphanumeric, spaces, dots, dashes, underscores, slashes, colons, commas, @
364
- if [[ "$value" =~ [\$\`\|\;\&\>\<\(\)\{\}\[\]\\] ]]; then
365
- return 1
366
- fi
311
+ # Reject values with dangerous shell metacharacters.
312
+ # SECURITY FIX (#691 wave): the previous [[ "$value" =~ [\$\`...] ]]
313
+ # bracket-regex matched NOTHING -- backslash escapes inside a bash regex
314
+ # bracket class are taken literally, so the guard ACCEPTED $(...), backticks,
315
+ # pipes, etc. (verified behaviorally: it returned 0 for "$(touch /tmp/x)").
316
+ # A `case` glob class has no such ambiguity and actually rejects the metachars.
317
+ # Allow alphanumeric, spaces, dots, dashes, underscores, slashes, colons,
318
+ # commas, @.
319
+ case "$value" in
320
+ *'$'* | *'`'* | *'|'* | *';'* | *'&'* | *'>'* | *'<'* | *'('* | *')'* \
321
+ | *'{'* | *'}'* | *'['* | *']'* | *'\'* )
322
+ return 1 ;;
323
+ esac
367
324
 
368
325
  # Reject values that are too long (DoS protection)
369
326
  if [ "${#value}" -gt "$max_length" ]; then
@@ -391,7 +348,8 @@ set_from_yaml() {
391
348
  local yaml_path="$2"
392
349
  local env_var="$3"
393
350
 
394
- # Skip if env var is already set
351
+ # Skip if env var is already set (env-wins guard; the shared helper also
352
+ # enforces this with override=0, but checking here avoids the extraction).
395
353
  if [ -n "${!env_var:-}" ]; then
396
354
  return 0
397
355
  fi
@@ -409,95 +367,40 @@ set_from_yaml() {
409
367
  # Use read to avoid xargs command execution risks
410
368
  value=$(grep -E "^\s*${escaped_key}:" "$file" 2>/dev/null | head -1 | sed -E 's/.*:\s*//' | sed 's/#.*//' | sed 's/^["\x27]//;s/["\x27]$//' | tr -d '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
411
369
 
412
- # Validate value before export (security check)
413
- if [ -n "$value" ] && [ "$value" != "null" ] && validate_yaml_value "$value"; then
370
+ # #691: delegate the env-wins guard + ${VAR}-expand + validate + export to the
371
+ # single shared helper (override=0 -> ambient env wins). Falls back to the
372
+ # original inline export if the lib is unavailable, so behavior is identical
373
+ # either way.
374
+ if declare -f loki_config_export_key >/dev/null 2>&1; then
375
+ loki_config_export_key "$env_var" "$value" 0 || true
376
+ elif [ -n "$value" ] && [ "$value" != "null" ] && validate_yaml_value "$value"; then
414
377
  export "$env_var=$value"
415
378
  fi
416
379
  }
417
380
 
418
- # Parse YAML using yq (proper parser)
381
+ # Parse YAML using yq (proper parser).
382
+ # #691: iterates the canonical LOKI_CONFIG_MAP and delegates the env-wins guard +
383
+ # expand + validate + export to the single shared helper (override=0 -> ambient
384
+ # env wins; auto-discovery contract unchanged). The inline 61-entry table that
385
+ # used to drift from parse_simple_yaml's 64 is gone.
419
386
  parse_yaml_with_yq() {
420
387
  local file="$1"
421
- local mappings=(
422
- "core.max_retries:LOKI_MAX_RETRIES"
423
- "core.base_wait:LOKI_BASE_WAIT"
424
- "core.max_wait:LOKI_MAX_WAIT"
425
- "core.skip_prereqs:LOKI_SKIP_PREREQS"
426
- "dashboard.enabled:LOKI_DASHBOARD"
427
- "dashboard.port:LOKI_DASHBOARD_PORT"
428
- "resources.check_interval:LOKI_RESOURCE_CHECK_INTERVAL"
429
- "resources.cpu_threshold:LOKI_RESOURCE_CPU_THRESHOLD"
430
- "resources.mem_threshold:LOKI_RESOURCE_MEM_THRESHOLD"
431
- "security.staged_autonomy:LOKI_STAGED_AUTONOMY"
432
- "security.audit_log:LOKI_AUDIT_LOG"
433
- "security.max_parallel_agents:LOKI_MAX_PARALLEL_AGENTS"
434
- "security.sandbox_mode:LOKI_SANDBOX_MODE"
435
- "security.allowed_paths:LOKI_ALLOWED_PATHS"
436
- "security.blocked_commands:LOKI_BLOCKED_COMMANDS"
437
- "phases.unit_tests:LOKI_PHASE_UNIT_TESTS"
438
- "phases.api_tests:LOKI_PHASE_API_TESTS"
439
- "phases.e2e_tests:LOKI_PHASE_E2E_TESTS"
440
- "phases.security:LOKI_PHASE_SECURITY"
441
- "phases.integration:LOKI_PHASE_INTEGRATION"
442
- "phases.code_review:LOKI_PHASE_CODE_REVIEW"
443
- "phases.web_research:LOKI_PHASE_WEB_RESEARCH"
444
- "phases.performance:LOKI_PHASE_PERFORMANCE"
445
- "phases.accessibility:LOKI_PHASE_ACCESSIBILITY"
446
- "phases.regression:LOKI_PHASE_REGRESSION"
447
- "phases.uat:LOKI_PHASE_UAT"
448
- "completion.promise:LOKI_COMPLETION_PROMISE"
449
- "completion.max_iterations:LOKI_MAX_ITERATIONS"
450
- "completion.perpetual_mode:LOKI_PERPETUAL_MODE"
451
- "completion.council.enabled:LOKI_COUNCIL_ENABLED"
452
- "completion.council.size:LOKI_COUNCIL_SIZE"
453
- "completion.council.threshold:LOKI_COUNCIL_THRESHOLD"
454
- "completion.council.check_interval:LOKI_COUNCIL_CHECK_INTERVAL"
455
- "completion.council.min_iterations:LOKI_COUNCIL_MIN_ITERATIONS"
456
- "completion.council.stagnation_limit:LOKI_COUNCIL_STAGNATION_LIMIT"
457
- "completion.uncertainty.escalation:LOKI_UNCERTAINTY_ESCALATION"
458
- "completion.uncertainty.rounds:LOKI_UNCERTAINTY_ROUNDS"
459
- "completion.uncertainty.nochange_min:LOKI_UNCERTAINTY_NOCHANGE_MIN"
460
- "completion.uncertainty.split_rounds:LOKI_UNCERTAINTY_SPLIT_ROUNDS"
461
- "model.prompt_repetition:LOKI_PROMPT_REPETITION"
462
- "model.confidence_routing:LOKI_CONFIDENCE_ROUTING"
463
- "model.autonomy_mode:LOKI_AUTONOMY_MODE"
464
- "parallel.enabled:LOKI_PARALLEL_MODE"
465
- "parallel.max_worktrees:LOKI_MAX_WORKTREES"
466
- "parallel.max_sessions:LOKI_MAX_PARALLEL_SESSIONS"
467
- "parallel.testing:LOKI_PARALLEL_TESTING"
468
- "parallel.docs:LOKI_PARALLEL_DOCS"
469
- "parallel.blog:LOKI_PARALLEL_BLOG"
470
- "parallel.auto_merge:LOKI_AUTO_MERGE"
471
- "complexity.tier:LOKI_COMPLEXITY"
472
- "github.import:LOKI_GITHUB_IMPORT"
473
- "github.pr:LOKI_GITHUB_PR"
474
- "github.sync:LOKI_GITHUB_SYNC"
475
- "github.repo:LOKI_GITHUB_REPO"
476
- "github.labels:LOKI_GITHUB_LABELS"
477
- "github.milestone:LOKI_GITHUB_MILESTONE"
478
- "github.assignee:LOKI_GITHUB_ASSIGNEE"
479
- "github.limit:LOKI_GITHUB_LIMIT"
480
- "github.pr_label:LOKI_GITHUB_PR_LABEL"
481
- "notifications.enabled:LOKI_NOTIFICATIONS"
482
- "notifications.sound:LOKI_NOTIFICATION_SOUND"
483
- )
484
-
485
- for mapping in "${mappings[@]}"; do
486
- local yaml_path="${mapping%%:*}"
487
- local env_var="${mapping##*:}"
388
+ if ! declare -p LOKI_CONFIG_MAP >/dev/null 2>&1; then
389
+ return 0
390
+ fi
488
391
 
489
- # Skip if env var is already set
490
- if [ -n "${!env_var:-}" ]; then
491
- continue
492
- fi
392
+ local mapping yaml_path env_var value
393
+ for mapping in "${LOKI_CONFIG_MAP[@]}"; do
394
+ yaml_path="${mapping%%:*}"
395
+ env_var="${mapping##*:}"
493
396
 
494
- # Extract value using yq
495
- local value
397
+ # Extract value using yq (env-wins guard is enforced by the shared helper).
496
398
  value=$(yq eval ".$yaml_path // \"\"" "$file" 2>/dev/null)
497
399
 
498
- # Set env var if value found and not empty/null
499
- # Also validate for security (prevent injection)
500
- if [ -n "$value" ] && [ "$value" != "null" ] && [ "$value" != "" ] && validate_yaml_value "$value"; then
400
+ if declare -f loki_config_export_key >/dev/null 2>&1; then
401
+ loki_config_export_key "$env_var" "$value" 0 || true
402
+ elif [ -n "$value" ] && [ "$value" != "null" ] && [ "$value" != "" ] \
403
+ && [ -z "${!env_var:-}" ] && validate_yaml_value "$value"; then
501
404
  export "$env_var=$value"
502
405
  fi
503
406
  done
@@ -588,9 +491,29 @@ STAGED_AUTONOMY=${LOKI_STAGED_AUTONOMY:-false} # Require plan approval
588
491
  AUDIT_LOG_ENABLED=${LOKI_AUDIT_LOG:-true} # Enable audit logging (on by default)
589
492
  MAX_PARALLEL_AGENTS=${LOKI_MAX_PARALLEL_AGENTS:-10} # Limit concurrent agents
590
493
  SANDBOX_MODE=${LOKI_SANDBOX_MODE:-false} # Docker sandbox mode (informational; the real dispatch reads LOKI_SANDBOX_MODE at autonomy/loki:1965 and execs sandbox.sh -- this var is not consumed in run.sh)
591
- ALLOWED_PATHS=${LOKI_ALLOWED_PATHS:-""} # Empty = all paths allowed
494
+ ALLOWED_PATHS=${LOKI_ALLOWED_PATHS:-""} # PARTIAL enforcement. When set, a sandbox custom --mount host path outside the allowlist is rejected in sandbox.sh (_sandbox_path_within_allowed), NOT in run.sh. Does NOT restrict provider-driven agent writes (run.sh never sees them); the sandbox container is the containment for those. Default empty = no restriction.
592
495
  BLOCKED_COMMANDS=${LOKI_BLOCKED_COMMANDS:-"rm -rf /,dd if=,mkfs,:(){ :|:& };:"}
593
496
 
497
+ # LOKI_SESSION_ID is woven into many filesystem paths (.loki/sessions/<id>/...,
498
+ # session locks, PID files, per-session state). It is operator/issue-derived
499
+ # (trusted-ish), but a value like "../../tmp/x" would escape the .loki/ tree.
500
+ # Sanitize ONCE here at intake: any char outside [A-Za-z0-9._-] is replaced with
501
+ # "_" and a leading run of dots/dashes is neutralized, so every downstream
502
+ # session path is contained. Unset stays unset (default behavior unchanged).
503
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
504
+ _loki_sid_raw="$LOKI_SESSION_ID"
505
+ _loki_sid_safe="${_loki_sid_raw//[^A-Za-z0-9._-]/_}"
506
+ # Strip leading dots/dashes so "../x" -> "x" cannot reference a parent dir
507
+ # or look like an option; collapse a now-empty result to a safe literal.
508
+ _loki_sid_safe="${_loki_sid_safe#"${_loki_sid_safe%%[!.-]*}"}"
509
+ [ -n "$_loki_sid_safe" ] || _loki_sid_safe="session"
510
+ if [ "$_loki_sid_safe" != "$_loki_sid_raw" ]; then
511
+ printf 'loki: WARNING sanitized unsafe LOKI_SESSION_ID %s -> %s\n' "$_loki_sid_raw" "$_loki_sid_safe" >&2
512
+ fi
513
+ export LOKI_SESSION_ID="$_loki_sid_safe"
514
+ unset _loki_sid_raw _loki_sid_safe
515
+ fi
516
+
594
517
  # Process Supervision (opt-in)
595
518
  WATCHDOG_ENABLED=${LOKI_WATCHDOG:-"false"} # Enable process health monitoring
596
519
  WATCHDOG_INTERVAL=${LOKI_WATCHDOG_INTERVAL:-30} # Check interval in seconds
@@ -1902,41 +1825,7 @@ detect_complexity() {
1902
1825
  }
1903
1826
 
1904
1827
  # Get phases based on complexity tier
1905
- get_complexity_phases() {
1906
- case "$DETECTED_COMPLEXITY" in
1907
- simple)
1908
- echo "3"
1909
- ;;
1910
- standard)
1911
- echo "6"
1912
- ;;
1913
- complex)
1914
- echo "8"
1915
- ;;
1916
- *)
1917
- echo "6" # Default to standard
1918
- ;;
1919
- esac
1920
- }
1921
-
1922
1828
  # Get phase names based on complexity tier
1923
- get_phase_names() {
1924
- case "$DETECTED_COMPLEXITY" in
1925
- simple)
1926
- echo "IMPLEMENT TEST DEPLOY"
1927
- ;;
1928
- standard)
1929
- echo "RESEARCH DESIGN IMPLEMENT TEST REVIEW DEPLOY"
1930
- ;;
1931
- complex)
1932
- echo "RESEARCH ARCHITECTURE DESIGN IMPLEMENT TEST REVIEW SECURITY DEPLOY"
1933
- ;;
1934
- *)
1935
- echo "RESEARCH DESIGN IMPLEMENT TEST REVIEW DEPLOY"
1936
- ;;
1937
- esac
1938
- }
1939
-
1940
1829
  #===============================================================================
1941
1830
  # Dynamic Tier Selection (RARV-aware model routing)
1942
1831
  #===============================================================================
@@ -2629,32 +2518,6 @@ send_notification() {
2629
2518
  return 0
2630
2519
  }
2631
2520
 
2632
- # Convenience notification functions
2633
- notify_task_started() {
2634
- local task_name="$1"
2635
- send_notification "Task Started" "$task_name" "low"
2636
- }
2637
-
2638
- notify_task_completed() {
2639
- local task_name="$1"
2640
- send_notification "Task Completed" "$task_name" "normal"
2641
- }
2642
-
2643
- notify_task_failed() {
2644
- local task_name="$1"
2645
- local error="${2:-Unknown error}"
2646
- send_notification "Task Failed" "$task_name: $error" "critical"
2647
- }
2648
-
2649
- notify_phase_complete() {
2650
- local phase_name="$1"
2651
- send_notification "Phase Complete" "$phase_name" "normal"
2652
- }
2653
-
2654
- notify_all_complete() {
2655
- send_notification "All Tasks Complete" "Loki Mode has finished all tasks" "normal"
2656
- }
2657
-
2658
2521
  notify_intervention_needed() {
2659
2522
  local reason="$1"
2660
2523
  # Delegate-then-notify: this helper ONLY fires the (gated) desktop ping. It
@@ -3722,23 +3585,6 @@ init_parallel_streams() {
3722
3585
  }
3723
3586
 
3724
3587
  # Spawn feature worktree from task
3725
- spawn_feature_stream() {
3726
- local feature_name="$1"
3727
- local task_description="$2"
3728
-
3729
- # Check worktree limit
3730
- # BUG-PAR-012: Worktree count subtracts 1 for main (git worktree list includes main)
3731
- local worktree_count_raw=$(git -C "$TARGET_DIR" worktree list 2>/dev/null | wc -l)
3732
- local worktree_count=$((worktree_count_raw > 0 ? worktree_count_raw - 1 : 0))
3733
- if [ "$worktree_count" -ge "$MAX_WORKTREES" ]; then
3734
- log_warn "Max worktrees reached ($MAX_WORKTREES). Queuing feature: $feature_name"
3735
- return 1
3736
- fi
3737
-
3738
- create_worktree "feature-$feature_name" "feature/$feature_name"
3739
- spawn_worktree_session "feature-$feature_name" "$task_description"
3740
- }
3741
-
3742
3588
  # Cleanup all worktrees on exit
3743
3589
  cleanup_parallel_streams() {
3744
3590
  log_header "Cleaning Up Parallel Streams"
@@ -4352,55 +4198,6 @@ EOF
4352
4198
  LAST_KNOWN_PHASE=""
4353
4199
 
4354
4200
  # Set the current phase and emit event if changed
4355
- set_phase() {
4356
- local new_phase="$1"
4357
- local orch_file=".loki/state/orchestrator.json"
4358
-
4359
- mkdir -p .loki/state
4360
-
4361
- # Get current phase
4362
- local current_phase=""
4363
- if [ -f "$orch_file" ]; then
4364
- current_phase=$(python3 -c "import json; print(json.load(open('$orch_file')).get('currentPhase', ''))" 2>/dev/null || echo "")
4365
- fi
4366
-
4367
- # Only emit event if phase changed
4368
- if [ "$new_phase" != "$current_phase" ]; then
4369
- emit_event_json "phase_change" \
4370
- "from=$current_phase" \
4371
- "to=$new_phase" \
4372
- "iteration=$ITERATION_COUNT"
4373
-
4374
- log_info "Phase changed: $current_phase -> $new_phase"
4375
-
4376
- # Update orchestrator state (atomic via temp file + mv)
4377
- # BUG ARCH-001 fix: prevent state corruption if process is killed mid-write
4378
- if [ -f "$orch_file" ]; then
4379
- python3 -c "
4380
- import json, sys, os, tempfile
4381
- orch_file = sys.argv[1]
4382
- new_phase = sys.argv[2]
4383
- with open(orch_file, 'r') as f:
4384
- data = json.load(f)
4385
- data['currentPhase'] = new_phase
4386
- orch_dir = os.path.dirname(orch_file)
4387
- fd, tmp = tempfile.mkstemp(dir=orch_dir, suffix='.json')
4388
- with os.fdopen(fd, 'w') as f:
4389
- json.dump(data, f, indent=2)
4390
- os.replace(tmp, orch_file)
4391
- " "$orch_file" "$new_phase" 2>/dev/null || true
4392
- fi
4393
- fi
4394
-
4395
- LAST_KNOWN_PHASE="$new_phase"
4396
-
4397
- # v7.5.12: Append a structured log entry to the active iteration task so
4398
- # the dashboard shows per-phase progress (REASON / ACT / REFLECT / VERIFY).
4399
- # No-op if no iteration is active or queue file is missing/corrupt.
4400
- append_iteration_task_log "${ITERATION_COUNT:-0}" "$new_phase" "info" \
4401
- "Phase entered: $new_phase" 2>/dev/null || true
4402
- }
4403
-
4404
4201
  # v7.5.12: append a log entry to the iteration-N task in in-progress.json.
4405
4202
  # Args: iteration, phase, level, message. All silent on failure -- this
4406
4203
  # must NEVER kill the run.
@@ -5729,438 +5526,6 @@ stop_status_monitor() {
5729
5526
  # Web Dashboard
5730
5527
  #===============================================================================
5731
5528
 
5732
- generate_dashboard() {
5733
- # Copy dashboard from skill installation (v4.0.0 with Anthropic design language)
5734
- local skill_dashboard="$SCRIPT_DIR/.loki/dashboard/index.html"
5735
- local project_name=$(basename "$(pwd)")
5736
- local project_path=$(pwd)
5737
-
5738
- if [ -f "$skill_dashboard" ]; then
5739
- # v7.5.8: Escape sed-special chars in project_name/project_path before
5740
- # interpolating into the substitution RHS. Without this, a project
5741
- # whose path contains '|' (the chosen sed delimiter), '&' (RHS
5742
- # backref), '\' or '/' would either break the substitution or allow
5743
- # attacker-controlled text to be smuggled into the served HTML.
5744
- local project_name_sed project_path_sed
5745
- project_name_sed=$(printf '%s' "$project_name" | sed -e 's/[\\&|/]/\\&/g')
5746
- project_path_sed=$(printf '%s' "$project_path" | sed -e 's/[\\&|/]/\\&/g')
5747
-
5748
- # Copy and inject project info
5749
- sed -e "s|Loki Mode</title>|Loki Mode - ${project_name_sed}</title>|g" \
5750
- -e "s|<div class=\"project-name\" id=\"project-name\">--|<div class=\"project-name\" id=\"project-name\">${project_name_sed}|g" \
5751
- -e "s|<div class=\"project-path\" id=\"project-path\" title=\"\">--|<div class=\"project-path\" id=\"project-path\" title=\"${project_path_sed}\">${project_path_sed}|g" \
5752
- "$skill_dashboard" > .loki/dashboard/index.html
5753
- log_info "Dashboard copied from skill installation"
5754
- log_info "Project: $project_name ($project_path)"
5755
- return
5756
- fi
5757
-
5758
- # Fallback: Generate basic dashboard if external file not found
5759
- cat > .loki/dashboard/index.html << 'DASHBOARD_HTML'
5760
- <!DOCTYPE html>
5761
- <html lang="en">
5762
- <head>
5763
- <meta charset="UTF-8">
5764
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
5765
- <title>Loki Mode Dashboard</title>
5766
- <style>
5767
- * { box-sizing: border-box; margin: 0; padding: 0; }
5768
- body {
5769
- font-family: 'Söhne', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
5770
- background: #FAF9F6;
5771
- color: #1A1A1A;
5772
- padding: 24px;
5773
- min-height: 100vh;
5774
- }
5775
- .header {
5776
- text-align: center;
5777
- padding: 32px 20px;
5778
- margin-bottom: 32px;
5779
- }
5780
- .header h1 {
5781
- color: #D97757;
5782
- font-size: 28px;
5783
- font-weight: 600;
5784
- letter-spacing: -0.5px;
5785
- margin-bottom: 8px;
5786
- }
5787
- .header .subtitle {
5788
- color: #666;
5789
- font-size: 14px;
5790
- font-weight: 400;
5791
- }
5792
- .header .phase {
5793
- display: inline-block;
5794
- margin-top: 16px;
5795
- padding: 8px 16px;
5796
- background: #FFF;
5797
- border: 1px solid #E5E3DE;
5798
- border-radius: 20px;
5799
- font-size: 13px;
5800
- color: #1A1A1A;
5801
- font-weight: 500;
5802
- }
5803
- .stats {
5804
- display: flex;
5805
- justify-content: center;
5806
- gap: 16px;
5807
- margin-bottom: 40px;
5808
- flex-wrap: wrap;
5809
- }
5810
- .stat {
5811
- background: #FFF;
5812
- border: 1px solid #E5E3DE;
5813
- border-radius: 12px;
5814
- padding: 20px 32px;
5815
- text-align: center;
5816
- min-width: 140px;
5817
- transition: box-shadow 0.2s ease;
5818
- }
5819
- .stat:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
5820
- .stat .number { font-size: 36px; font-weight: 600; margin-bottom: 4px; }
5821
- .stat .label { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; }
5822
- .stat.pending .number { color: #D97757; }
5823
- .stat.progress .number { color: #5B8DEF; }
5824
- .stat.completed .number { color: #2E9E6E; }
5825
- .stat.failed .number { color: #D44F4F; }
5826
- .stat.agents .number { color: #9B6DD6; }
5827
- .section-header {
5828
- text-align: center;
5829
- font-size: 16px;
5830
- font-weight: 600;
5831
- color: #666;
5832
- margin: 40px 0 20px 0;
5833
- text-transform: uppercase;
5834
- letter-spacing: 1px;
5835
- }
5836
- .agents-grid {
5837
- display: grid;
5838
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
5839
- gap: 16px;
5840
- max-width: 1400px;
5841
- margin: 0 auto 40px auto;
5842
- }
5843
- .agent-card {
5844
- background: #FFF;
5845
- border: 1px solid #E5E3DE;
5846
- border-radius: 12px;
5847
- padding: 16px;
5848
- transition: box-shadow 0.2s ease, border-color 0.2s ease;
5849
- }
5850
- .agent-card:hover {
5851
- box-shadow: 0 4px 12px rgba(0,0,0,0.06);
5852
- border-color: #9B6DD6;
5853
- }
5854
- .agent-card .agent-header {
5855
- display: flex;
5856
- justify-content: space-between;
5857
- align-items: flex-start;
5858
- margin-bottom: 12px;
5859
- }
5860
- .agent-card .agent-id {
5861
- font-size: 11px;
5862
- color: #999;
5863
- font-family: monospace;
5864
- }
5865
- .agent-card .model-badge {
5866
- padding: 4px 10px;
5867
- border-radius: 6px;
5868
- font-size: 10px;
5869
- font-weight: 600;
5870
- text-transform: uppercase;
5871
- letter-spacing: 0.5px;
5872
- }
5873
- .agent-card .model-badge.sonnet {
5874
- background: #E8F0FD;
5875
- color: #5B8DEF;
5876
- }
5877
- .agent-card .model-badge.haiku {
5878
- background: #FFF4E6;
5879
- color: #F59E0B;
5880
- }
5881
- .agent-card .model-badge.opus {
5882
- background: #F3E8FF;
5883
- color: #9B6DD6;
5884
- }
5885
- .agent-card .agent-type {
5886
- font-size: 14px;
5887
- font-weight: 600;
5888
- color: #1A1A1A;
5889
- margin-bottom: 8px;
5890
- }
5891
- .agent-card .agent-status {
5892
- display: inline-block;
5893
- padding: 3px 8px;
5894
- border-radius: 4px;
5895
- font-size: 10px;
5896
- font-weight: 500;
5897
- margin-bottom: 12px;
5898
- }
5899
- .agent-card .agent-status.active {
5900
- background: #E6F5EE;
5901
- color: #2E9E6E;
5902
- }
5903
- .agent-card .agent-status.completed {
5904
- background: #F0EFEA;
5905
- color: #666;
5906
- }
5907
- .agent-card .agent-work {
5908
- font-size: 12px;
5909
- color: #666;
5910
- line-height: 1.5;
5911
- margin-bottom: 8px;
5912
- }
5913
- .agent-card .agent-meta {
5914
- display: flex;
5915
- gap: 12px;
5916
- font-size: 11px;
5917
- color: #999;
5918
- margin-top: 8px;
5919
- padding-top: 8px;
5920
- border-top: 1px solid #F0EFEA;
5921
- }
5922
- .agent-card .agent-meta span {
5923
- display: flex;
5924
- align-items: center;
5925
- gap: 4px;
5926
- }
5927
- .columns {
5928
- display: flex;
5929
- gap: 20px;
5930
- overflow-x: auto;
5931
- padding-bottom: 24px;
5932
- max-width: 1400px;
5933
- margin: 0 auto;
5934
- }
5935
- .column {
5936
- flex: 1;
5937
- min-width: 300px;
5938
- max-width: 350px;
5939
- background: #FFF;
5940
- border: 1px solid #E5E3DE;
5941
- border-radius: 12px;
5942
- padding: 20px;
5943
- }
5944
- .column h2 {
5945
- font-size: 13px;
5946
- font-weight: 600;
5947
- color: #666;
5948
- margin-bottom: 16px;
5949
- display: flex;
5950
- align-items: center;
5951
- gap: 10px;
5952
- text-transform: uppercase;
5953
- letter-spacing: 0.5px;
5954
- }
5955
- .column h2 .count {
5956
- background: #F0EFEA;
5957
- padding: 3px 10px;
5958
- border-radius: 12px;
5959
- font-size: 11px;
5960
- color: #1A1A1A;
5961
- }
5962
- .column.pending h2 .count { background: #FCEEE8; color: #D97757; }
5963
- .column.progress h2 .count { background: #E8F0FD; color: #5B8DEF; }
5964
- .column.completed h2 .count { background: #E6F5EE; color: #2E9E6E; }
5965
- .column.failed h2 .count { background: #FCE8E8; color: #D44F4F; }
5966
- .task {
5967
- background: #FAF9F6;
5968
- border: 1px solid #E5E3DE;
5969
- border-radius: 8px;
5970
- padding: 14px;
5971
- margin-bottom: 12px;
5972
- transition: border-color 0.2s ease;
5973
- }
5974
- .task:hover { border-color: #D97757; }
5975
- .task .id { font-size: 10px; color: #999; margin-bottom: 6px; font-family: monospace; }
5976
- .task .type {
5977
- display: inline-block;
5978
- background: #FCEEE8;
5979
- color: #D97757;
5980
- padding: 3px 10px;
5981
- border-radius: 4px;
5982
- font-size: 11px;
5983
- font-weight: 500;
5984
- margin-bottom: 8px;
5985
- }
5986
- .task .title { font-size: 13px; color: #1A1A1A; line-height: 1.5; }
5987
- .task .error {
5988
- font-size: 11px;
5989
- color: #D44F4F;
5990
- margin-top: 10px;
5991
- padding: 10px;
5992
- background: #FCE8E8;
5993
- border-radius: 6px;
5994
- font-family: monospace;
5995
- }
5996
- .refresh {
5997
- position: fixed;
5998
- bottom: 24px;
5999
- right: 24px;
6000
- background: #D97757;
6001
- color: white;
6002
- border: none;
6003
- padding: 12px 24px;
6004
- border-radius: 8px;
6005
- cursor: pointer;
6006
- font-size: 14px;
6007
- font-weight: 500;
6008
- transition: background 0.2s ease;
6009
- box-shadow: 0 4px 12px rgba(217, 119, 87, 0.3);
6010
- }
6011
- .refresh:hover { background: #C56747; }
6012
- .updated {
6013
- text-align: center;
6014
- color: #999;
6015
- font-size: 12px;
6016
- margin-top: 24px;
6017
- }
6018
- .empty {
6019
- color: #999;
6020
- font-size: 13px;
6021
- text-align: center;
6022
- padding: 24px;
6023
- font-style: italic;
6024
- }
6025
- .powered-by {
6026
- text-align: center;
6027
- margin-top: 40px;
6028
- padding-top: 24px;
6029
- border-top: 1px solid #E5E3DE;
6030
- color: #999;
6031
- font-size: 12px;
6032
- }
6033
- .powered-by span { color: #D97757; font-weight: 500; }
6034
- </style>
6035
- </head>
6036
- <body>
6037
- <div class="header">
6038
- <h1>LOKI MODE</h1>
6039
- <div class="subtitle">Autonomous Spec-to-Product System</div>
6040
- <div class="phase" id="phase">Loading...</div>
6041
- </div>
6042
- <div class="stats">
6043
- <div class="stat agents"><div class="number" id="agents-count">-</div><div class="label">Active Agents</div></div>
6044
- <div class="stat pending"><div class="number" id="pending-count">-</div><div class="label">Pending</div></div>
6045
- <div class="stat progress"><div class="number" id="progress-count">-</div><div class="label">In Progress</div></div>
6046
- <div class="stat completed"><div class="number" id="completed-count">-</div><div class="label">Completed</div></div>
6047
- <div class="stat failed"><div class="number" id="failed-count">-</div><div class="label">Failed</div></div>
6048
- </div>
6049
- <div class="section-header">Active Agents</div>
6050
- <div class="agents-grid" id="agents-grid"></div>
6051
- <div class="section-header">Task Queue</div>
6052
- <div class="columns">
6053
- <div class="column pending"><h2>Pending <span class="count" id="pending-badge">0</span></h2><div id="pending-tasks"></div></div>
6054
- <div class="column progress"><h2>In Progress <span class="count" id="progress-badge">0</span></h2><div id="progress-tasks"></div></div>
6055
- <div class="column completed"><h2>Completed <span class="count" id="completed-badge">0</span></h2><div id="completed-tasks"></div></div>
6056
- <div class="column failed"><h2>Failed <span class="count" id="failed-badge">0</span></h2><div id="failed-tasks"></div></div>
6057
- </div>
6058
- <div class="updated" id="updated">Last updated: -</div>
6059
- <div class="powered-by">Powered by <span>${PROVIDER_DISPLAY_NAME:-Claude}</span></div>
6060
- <button class="refresh" onclick="loadData()">Refresh</button>
6061
- <script>
6062
- async function loadJSON(path) {
6063
- try {
6064
- const res = await fetch(path + '?t=' + Date.now());
6065
- if (!res.ok) return [];
6066
- const text = await res.text();
6067
- if (!text.trim()) return [];
6068
- const data = JSON.parse(text);
6069
- return Array.isArray(data) ? data : (data.tasks || data.agents || []);
6070
- } catch { return []; }
6071
- }
6072
- function getModelClass(model) {
6073
- if (!model) return 'sonnet';
6074
- const m = model.toLowerCase();
6075
- if (m.includes('haiku')) return 'haiku';
6076
- if (m.includes('opus')) return 'opus';
6077
- return 'sonnet';
6078
- }
6079
- function formatDuration(isoDate) {
6080
- if (!isoDate) return 'Unknown';
6081
- const start = new Date(isoDate);
6082
- const now = new Date();
6083
- const seconds = Math.floor((now - start) / 1000);
6084
- if (seconds < 60) return seconds + 's';
6085
- if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
6086
- return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
6087
- }
6088
- function renderAgent(agent) {
6089
- const modelClass = getModelClass(agent.model);
6090
- const modelName = agent.model || 'Sonnet 4.5';
6091
- const agentType = agent.agent_type || 'general-purpose';
6092
- const status = agent.status === 'completed' ? 'completed' : 'active';
6093
- const currentTask = agent.current_task || (agent.tasks_completed && agent.tasks_completed.length > 0
6094
- ? 'Completed: ' + agent.tasks_completed.join(', ')
6095
- : 'Initializing...');
6096
- const duration = formatDuration(agent.spawned_at);
6097
- const tasksCount = agent.tasks_completed ? agent.tasks_completed.length : 0;
6098
-
6099
- return `
6100
- <div class="agent-card">
6101
- <div class="agent-header">
6102
- <div class="agent-id">${agent.agent_id || 'Unknown'}</div>
6103
- <div class="model-badge ${modelClass}">${modelName}</div>
6104
- </div>
6105
- <div class="agent-type">${agentType}</div>
6106
- <div class="agent-status ${status}">${status}</div>
6107
- <div class="agent-work">${currentTask}</div>
6108
- <div class="agent-meta">
6109
- <span>${duration}</span>
6110
- <span>${tasksCount} tasks</span>
6111
- </div>
6112
- </div>
6113
- `;
6114
- }
6115
- function renderTask(task) {
6116
- const payload = task.payload || {};
6117
- const title = payload.description || payload.action || task.type || 'Task';
6118
- const error = task.lastError ? `<div class="error">${task.lastError}</div>` : '';
6119
- return `<div class="task"><div class="id">${task.id}</div><span class="type">${task.type || 'general'}</span><div class="title">${title}</div>${error}</div>`;
6120
- }
6121
- async function loadData() {
6122
- const [pending, progress, completed, failed, agents] = await Promise.all([
6123
- loadJSON('../queue/pending.json'),
6124
- loadJSON('../queue/in-progress.json'),
6125
- loadJSON('../queue/completed.json'),
6126
- loadJSON('../queue/failed.json'),
6127
- loadJSON('../state/agents.json')
6128
- ]);
6129
-
6130
- // Agent stats
6131
- document.getElementById('agents-count').textContent = agents.length;
6132
- document.getElementById('agents-grid').innerHTML = agents.length
6133
- ? agents.map(renderAgent).join('')
6134
- : '<div class="empty">No active agents</div>';
6135
-
6136
- // Task stats
6137
- document.getElementById('pending-count').textContent = pending.length;
6138
- document.getElementById('progress-count').textContent = progress.length;
6139
- document.getElementById('completed-count').textContent = completed.length;
6140
- document.getElementById('failed-count').textContent = failed.length;
6141
- document.getElementById('pending-badge').textContent = pending.length;
6142
- document.getElementById('progress-badge').textContent = progress.length;
6143
- document.getElementById('completed-badge').textContent = completed.length;
6144
- document.getElementById('failed-badge').textContent = failed.length;
6145
- document.getElementById('pending-tasks').innerHTML = pending.length ? pending.map(renderTask).join('') : '<div class="empty">No pending tasks</div>';
6146
- document.getElementById('progress-tasks').innerHTML = progress.length ? progress.map(renderTask).join('') : '<div class="empty">No tasks in progress</div>';
6147
- document.getElementById('completed-tasks').innerHTML = completed.length ? completed.slice(-10).reverse().map(renderTask).join('') : '<div class="empty">No completed tasks</div>';
6148
- document.getElementById('failed-tasks').innerHTML = failed.length ? failed.map(renderTask).join('') : '<div class="empty">No failed tasks</div>';
6149
-
6150
- try {
6151
- const state = await fetch('../state/orchestrator.json?t=' + Date.now()).then(r => r.json());
6152
- document.getElementById('phase').textContent = 'Phase: ' + (state.currentPhase || 'UNKNOWN');
6153
- } catch { document.getElementById('phase').textContent = 'Phase: UNKNOWN'; }
6154
- document.getElementById('updated').textContent = 'Last updated: ' + new Date().toLocaleTimeString();
6155
- }
6156
- loadData();
6157
- setInterval(loadData, 3000);
6158
- </script>
6159
- </body>
6160
- </html>
6161
- DASHBOARD_HTML
6162
- }
6163
-
6164
5529
  update_agents_state() {
6165
5530
  # Aggregate agent information from .agent/sub-agents/*.json into .loki/state/agents.json
6166
5531
  local agents_dir=".agent/sub-agents"
@@ -6828,6 +6193,28 @@ check_command_allowed() {
6828
6193
  return 0
6829
6194
  }
6830
6195
 
6196
+ # A5 (ALLOWED_PATHS enforcement) -- HONEST THREAT MODEL
6197
+ # --------------------------------------------------------------------------
6198
+ # The DOMINANT file-write path in an autonomous build is the PROVIDER CLI
6199
+ # (claude / codex / cline / aider) writing files directly. run.sh hands the
6200
+ # work to that CLI and never sees those writes as shell redirections, so run.sh
6201
+ # CANNOT intercept provider-driven writes. ALLOWED_PATHS therefore cannot be a
6202
+ # complete sandbox; the real containment for provider writes is the OS user +
6203
+ # the LOKI_SANDBOX_MODE container (sandbox.sh, cap-drop/seccomp/read-only
6204
+ # mounts), not this shell.
6205
+ #
6206
+ # Where ALLOWED_PATHS is actually enforced: the sandbox custom --mount surface.
6207
+ # That enforcement lives in sandbox.sh, NOT here. When ALLOWED_PATHS is set, a
6208
+ # host path that loki is about to bind-mount into the container is checked by
6209
+ # _sandbox_path_within_allowed (autonomy/sandbox.sh), and an operator-supplied
6210
+ # `loki sandbox run` argv is checked by _sandbox_command_allowed there too.
6211
+ # run.sh itself does not consume ALLOWED_PATHS for any write decision -- it has
6212
+ # no run.sh-controlled host-write surface to gate -- so there is no enforcement
6213
+ # helper here. (A run.sh-local helper existed previously but had zero call
6214
+ # sites; it was removed so the only enforcement path is the one that genuinely
6215
+ # runs. See tests/test-allowed-paths-a5.sh, which exercises the sandbox.sh
6216
+ # functions.)
6217
+
6831
6218
  #===============================================================================
6832
6219
  # Cross-Project Learnings Database
6833
6220
  #===============================================================================
@@ -6853,34 +6240,6 @@ init_learnings_db() {
6853
6240
  log_info "Learnings database initialized at: $learnings_dir"
6854
6241
  }
6855
6242
 
6856
- save_learning() {
6857
- # Save a learning to the cross-project database
6858
- local learning_type="$1" # pattern, mistake, success
6859
- local category="$2"
6860
- local description="$3"
6861
- local project="${4:-$(basename "$(pwd)")}"
6862
-
6863
- local learnings_dir="${HOME}/.loki/learnings"
6864
- local target_file="$learnings_dir/${learning_type}s.jsonl"
6865
-
6866
- if [ ! -d "$learnings_dir" ]; then
6867
- init_learnings_db
6868
- fi
6869
-
6870
- local learning_entry
6871
- if command -v jq >/dev/null 2>&1; then
6872
- learning_entry=$(jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg proj "$project" --arg cat "$category" --arg desc "$description" '{timestamp:$ts,project:$proj,category:$cat,description:$desc}')
6873
- else
6874
- local safe_proj safe_cat safe_desc
6875
- safe_proj=$(printf '%s' "$project" | sed 's/["\\]/\\&/g; s/\n/\\n/g')
6876
- safe_cat=$(printf '%s' "$category" | sed 's/["\\]/\\&/g; s/\n/\\n/g')
6877
- safe_desc=$(printf '%s' "$description" | sed 's/["\\]/\\&/g; s/\n/\\n/g')
6878
- learning_entry="{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":\"$safe_proj\",\"category\":\"$safe_cat\",\"description\":\"$safe_desc\"}"
6879
- fi
6880
- echo "$learning_entry" >> "$target_file"
6881
- log_info "Saved $learning_type: $category"
6882
- }
6883
-
6884
6243
  get_relevant_learnings() {
6885
6244
  # Get learnings relevant to the current context
6886
6245
  local context="$1"
@@ -7882,24 +7241,6 @@ with open(gate_file, 'w') as f:
7882
7241
  " 2>/dev/null || true
7883
7242
  }
7884
7243
 
7885
- get_gate_failure_count() {
7886
- local gate_name="$1"
7887
- local gate_file="${TARGET_DIR:-.}/.loki/quality/gate-failure-count.json"
7888
- [ -f "$gate_file" ] || { echo "0"; return; }
7889
-
7890
- _GATE_FILE="$gate_file" _GATE_NAME="$gate_name" python3 -c "
7891
- import json, os
7892
- gate_file = os.environ['_GATE_FILE']
7893
- gate_name = os.environ['_GATE_NAME']
7894
- try:
7895
- with open(gate_file) as f:
7896
- counts = json.load(f)
7897
- print(counts.get(gate_name, 0))
7898
- except (json.JSONDecodeError, FileNotFoundError, OSError):
7899
- print(0)
7900
- " 2>/dev/null || echo "0"
7901
- }
7902
-
7903
7244
  # ============================================================================
7904
7245
  # Hard Quality Gate: Test Coverage (v6.7.0)
7905
7246
  # Detects test runner and runs tests with coverage reporting
@@ -10102,182 +9443,6 @@ DA_AGG_PATCH
10102
9443
  # Only runs when complexity >= standard (6+ agents).
10103
9444
  #===============================================================================
10104
9445
 
10105
- run_adversarial_testing() {
10106
- local loki_dir="${TARGET_DIR:-.}/.loki"
10107
- local adversarial_dir="$loki_dir/quality/adversarial"
10108
- local test_id
10109
- test_id="adversarial-$(date -u +%Y%m%dT%H%M%SZ)-${ITERATION_COUNT:-0}"
10110
- mkdir -p "$adversarial_dir/$test_id"
10111
-
10112
- # Only run for Standard+ complexity
10113
- local complexity="${LOKI_COMPLEXITY:-auto}"
10114
- if [ "$complexity" = "simple" ]; then
10115
- log_debug "Adversarial testing skipped: simple complexity tier"
10116
- return 0
10117
- fi
10118
-
10119
- # Check if adversarial testing is disabled
10120
- if [ "${LOKI_ADVERSARIAL_TESTING:-true}" = "false" ]; then
10121
- log_debug "Adversarial testing disabled via LOKI_ADVERSARIAL_TESTING=false"
10122
- return 0
10123
- fi
10124
-
10125
- log_header "ADVERSARIAL TESTING: $test_id"
10126
-
10127
- # Get diff for adversarial analysis
10128
- local diff_content
10129
- diff_content=$(git -C "${TARGET_DIR:-.}" diff HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --cached 2>/dev/null || echo "")
10130
- if [ -z "$diff_content" ]; then
10131
- log_info "Adversarial testing: No diff to test, skipping"
10132
- return 0
10133
- fi
10134
-
10135
- local changed_files
10136
- 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 "")
10137
-
10138
- # Write analysis files -- use printf to prevent shell variable expansion (#78)
10139
- local diff_file="$adversarial_dir/$test_id/diff.txt"
10140
- local files_file="$adversarial_dir/$test_id/files.txt"
10141
- printf '%s\n' "$diff_content" > "$diff_file"
10142
- printf '%s\n' "$changed_files" > "$files_file"
10143
-
10144
- # Build adversarial prompt -- use heredoc with quoted delimiter to prevent
10145
- # shell variable expansion in diff content (fixes #78)
10146
- local files_content changed_content
10147
- files_content=$(cat "$files_file")
10148
- changed_content=$(head -500 "$diff_file")
10149
- local adversarial_prompt
10150
- read -r -d '' adversarial_prompt <<'ADVERSARIAL_EOF' || true
10151
- You are an ADVERSARIAL TESTER. Your goal is to BREAK the implementation.
10152
-
10153
- CHANGED FILES:
10154
- __FILES_PLACEHOLDER__
10155
-
10156
- DIFF:
10157
- __DIFF_PLACEHOLDER__
10158
-
10159
- YOUR MISSION:
10160
- 1. Find edge cases that will cause crashes or incorrect behavior
10161
- 2. Identify inputs that bypass validation
10162
- 3. Find race conditions or concurrency issues
10163
- 4. Discover security vulnerabilities (injection, auth bypass, SSRF)
10164
- 5. Find resource exhaustion vectors (unbounded loops, memory leaks)
10165
- 6. Identify error handling gaps (missing try/catch, unchecked returns)
10166
-
10167
- OUTPUT FORMAT (STRICT):
10168
- ATTACK_VECTORS:
10169
- - [severity] [category] description | reproduction steps
10170
- Severity: Critical, High, Medium, Low
10171
- Category: crash, security, correctness, performance, resource
10172
-
10173
- SUGGESTED_TESTS:
10174
- - Test description that would catch this issue
10175
-
10176
- OVERALL_RISK: HIGH or MEDIUM or LOW
10177
- ADVERSARIAL_EOF
10178
- # Substitute placeholders with actual content (safe from shell expansion)
10179
- adversarial_prompt="${adversarial_prompt/__FILES_PLACEHOLDER__/$files_content}"
10180
- adversarial_prompt="${adversarial_prompt/__DIFF_PLACEHOLDER__/$changed_content}"
10181
-
10182
- local result_file="$adversarial_dir/$test_id/result.txt"
10183
-
10184
- # Run adversarial agent
10185
- log_info "Spawning adversarial agent..."
10186
- case "${PROVIDER_NAME:-claude}" in
10187
- claude)
10188
- if command -v claude &>/dev/null; then
10189
- # EMBED 2 + 3 (v7.33.0). Adversarial probe subcall.
10190
- # $adversarial_prompt is fully self-contained (instructions +
10191
- # changed files + diff inlined via the heredoc above) and output
10192
- # is captured to $result_file. So:
10193
- # EMBED 2 (--bare): no hooks/LSP/CLAUDE.md/MCP needed; cheaper.
10194
- # Opt out LOKI_BARE_SUBCALLS=0.
10195
- # EMBED 3 (--disallowedTools): keep an adversarial agent from
10196
- # casually mutating the tree. Deny Edit/Write/NotebookEdit +
10197
- # git mutation forms (incl. git -C / --git-dir evasions);
10198
- # read-only git stays allowed. Guardrail, not a sandbox.
10199
- # Opt out LOKI_REVIEW_TOOL_GUARD=0.
10200
- local _adv_argv=("--dangerously-skip-permissions")
10201
- if type loki_subcall_bare_enabled >/dev/null 2>&1 && loki_subcall_bare_enabled; then
10202
- _adv_argv+=("--bare")
10203
- fi
10204
- if type loki_review_guard_enabled >/dev/null 2>&1 && loki_review_guard_enabled; then
10205
- _adv_argv+=("--disallowedTools" "$(loki_review_guard_denylist)")
10206
- fi
10207
- # EMBED 3b (--allowedTools, #167): positive least-privilege
10208
- # allowlist. DEFAULT OFF (opt-in LOKI_REVIEW_ALLOWLIST=1).
10209
- # Emitted ALONGSIDE the denylist (deny precedence verified
10210
- # live, holds under --dangerously-skip-permissions). See
10211
- # loki_review_allowlist.
10212
- if type loki_review_allowlist_enabled >/dev/null 2>&1 && loki_review_allowlist_enabled; then
10213
- _adv_argv+=("--allowedTools" "$(loki_review_allowlist)")
10214
- fi
10215
- # caveman HARD-SUPPRESS (parsed output): the adversarial probe's
10216
- # output is parsed for findings/severity. Disable caveman
10217
- # unconditionally (CAVEMAN_DEFAULT_MODE=off) so compression cannot
10218
- # drop or reword a finding. No-op when caveman is absent.
10219
- CAVEMAN_DEFAULT_MODE=off \
10220
- claude "${_adv_argv[@]}" -p "$adversarial_prompt" \
10221
- --output-format text > "$result_file" 2>/dev/null || true
10222
- fi
10223
- ;;
10224
- codex)
10225
- if command -v codex &>/dev/null; then
10226
- codex exec --sandbox workspace-write --skip-git-repo-check "$adversarial_prompt" \
10227
- > "$result_file" 2>/dev/null || true
10228
- fi
10229
- ;;
10230
- cline)
10231
- if command -v cline &>/dev/null; then
10232
- invoke_cline_capture "$adversarial_prompt" \
10233
- > "$result_file" 2>/dev/null || true
10234
- fi
10235
- ;;
10236
- aider)
10237
- if command -v aider &>/dev/null; then
10238
- invoke_aider_capture "$adversarial_prompt" \
10239
- > "$result_file" 2>/dev/null || true
10240
- fi
10241
- ;;
10242
- *)
10243
- echo "ATTACK_VECTORS: None (unknown provider)" > "$result_file"
10244
- echo "OVERALL_RISK: LOW" >> "$result_file"
10245
- ;;
10246
- esac
10247
-
10248
- if [ ! -s "$result_file" ]; then
10249
- log_warn "Adversarial agent produced no output"
10250
- return 0
10251
- fi
10252
-
10253
- # Parse risk level
10254
- local risk_level
10255
- risk_level=$(grep -i "OVERALL_RISK:" "$result_file" | head -1 | sed 's/.*OVERALL_RISK:[[:space:]]*//' | awk '{print toupper($1)}')
10256
-
10257
- # Count critical/high attack vectors
10258
- local critical_count high_count
10259
- critical_count=$(grep -ci "\[critical\]" "$result_file" 2>/dev/null || echo "0")
10260
- high_count=$(grep -ci "\[high\]" "$result_file" 2>/dev/null || echo "0")
10261
-
10262
- log_info "Adversarial testing complete: risk=$risk_level, critical=$critical_count, high=$high_count"
10263
-
10264
- emit_event_json "adversarial_test_complete" \
10265
- "test_id=$test_id" \
10266
- "risk_level=${risk_level:-UNKNOWN}" \
10267
- "critical_count=$critical_count" \
10268
- "high_count=$high_count" \
10269
- "iteration=$ITERATION_COUNT"
10270
-
10271
- # Block on critical findings
10272
- if [ "$critical_count" -gt 0 ]; then
10273
- log_error "ADVERSARIAL TEST BLOCKED: $critical_count critical attack vectors found"
10274
- log_error "Details: $adversarial_dir/$test_id/result.txt"
10275
- return 1
10276
- fi
10277
-
10278
- return 0
10279
- }
10280
-
10281
9446
  load_solutions_context() {
10282
9447
  # Load relevant structured solutions for the current task context
10283
9448
  local context="$1"
@@ -10468,6 +9633,18 @@ create_checkpoint() {
10468
9633
  cp ".loki/$f" "$cp_dir/$f" 2>/dev/null || true
10469
9634
  fi
10470
9635
  done
9636
+ # A6: under a namespaced session the live state file is at
9637
+ # .loki/sessions/<id>/autonomy-state.json, which the legacy loop above misses.
9638
+ # Snapshot it into the checkpoint as autonomy-state.json so a rollback still
9639
+ # captures the current run's machine state. (Default `loki start` has no
9640
+ # LOKI_SESSION_ID, so this block is skipped and the loop above is unchanged.)
9641
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
9642
+ local _ns_state_file
9643
+ _ns_state_file="$(_loki_state_file)"
9644
+ if [ -f "$_ns_state_file" ]; then
9645
+ cp "$_ns_state_file" "$cp_dir/autonomy-state.json" 2>/dev/null || true
9646
+ fi
9647
+ fi
10471
9648
 
10472
9649
  # R6: capture a real working-tree snapshot so code can be truly undone later.
10473
9650
  # Loki does not commit per iteration, so git_sha (HEAD) cannot reconstruct
@@ -10565,118 +9742,47 @@ print(json.dumps({'id':m['id'],'ts':m['timestamp'],'iter':m['iteration'],'task':
10565
9742
  # it without parsing stdout (log_info writes to stdout, so command-substitution
10566
9743
  # capture would include log lines).
10567
9744
  _LAST_CHECKPOINT_ID="$checkpoint_id"
10568
- }
10569
-
10570
- rollback_to_checkpoint() {
10571
- # Rollback state files to a specific checkpoint
10572
- # Args: $1 = checkpoint_id
10573
- local checkpoint_id="$1"
10574
- local checkpoint_dir=".loki/state/checkpoints"
10575
-
10576
- # Validate checkpoint ID (prevent path traversal)
10577
- if [[ ! "$checkpoint_id" =~ ^[a-zA-Z0-9_-]+$ ]]; then
10578
- log_error "Invalid checkpoint ID: must be alphanumeric, hyphens, underscores only"
10579
- return 1
10580
- fi
10581
-
10582
- local cp_dir="${checkpoint_dir}/${checkpoint_id}"
10583
-
10584
- if [ ! -d "$cp_dir" ]; then
10585
- log_error "Checkpoint not found: ${checkpoint_id}"
10586
- return 1
10587
- fi
10588
-
10589
- # Read checkpoint metadata
10590
- local git_sha
10591
- git_sha=$(_CP_META="${cp_dir}/metadata.json" python3 -c "import json, os; print(json.load(open(os.environ['_CP_META']))['git_sha'])" 2>/dev/null || echo "")
10592
-
10593
- log_warn "Rolling back to checkpoint: ${checkpoint_id}"
10594
-
10595
- # R6 re-undoability invariant: force a pre-rollback snapshot of CURRENT state
10596
- # before overwriting, even if the git tree is clean (the .loki/ state we are
10597
- # about to clobber is not git-tracked). _LOKI_CP_FORCE bypasses the clean-tree
10598
- # guard. Capture the snapshot id so we can tell the user how to undo the undo.
10599
- _LOKI_CP_FORCE=1 create_checkpoint "pre-rollback snapshot (before restoring ${checkpoint_id})" "rollback"
10600
- local pre_rollback_id="${_LAST_CHECKPOINT_ID:-}"
10601
- if [ -n "$pre_rollback_id" ]; then
10602
- log_info "Saved prior state as ${pre_rollback_id} (undo this rollback with: loki rollback to ${pre_rollback_id})"
10603
- fi
10604
-
10605
- # Restore state files (R6: CONTINUITY.md restores iteration/conversation context)
10606
- for f in state/orchestrator.json queue/pending.json queue/completed.json queue/in-progress.json queue/current-task.json CONTINUITY.md; do
10607
- if [ -f "${cp_dir}/${f}" ]; then
10608
- local target_dir=".loki/$(dirname "$f")"
10609
- mkdir -p "$target_dir"
10610
- cp "${cp_dir}/${f}" ".loki/${f}" 2>/dev/null || true
10611
- fi
10612
- done
10613
-
10614
- # Log the rollback (use python3 for safe JSON serialization)
10615
- # v7.5.10: route through safe_append_event_jsonl() so parallel-worktree
10616
- # rollbacks cannot interleave partial JSONL lines (POSIX append is
10617
- # only atomic for <PIPE_BUF and not all platforms honor it).
10618
- local timestamp rb_event
10619
- timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
10620
- rb_event=$(_RB_CPID="$checkpoint_id" _RB_SHA="$git_sha" _RB_TS="$timestamp" \
10621
- python3 -c "
10622
- import json,os
10623
- print(json.dumps({'event':'rollback','checkpoint':os.environ['_RB_CPID'],'git_sha':os.environ['_RB_SHA'],'timestamp':os.environ['_RB_TS']}))
10624
- " 2>/dev/null) || rb_event=""
10625
- if [ -n "$rb_event" ]; then
10626
- # Source the emit lib once per call to get safe_append_event_jsonl.
10627
- # Lib-only mode skips the emit script's normal CLI execution.
10628
- # shellcheck disable=SC1091
10629
- if [ -z "${_LOKI_EMIT_LIB_LOADED:-}" ]; then
10630
- LOKI_EMIT_LIB_ONLY=1 . "$(dirname "${BASH_SOURCE[0]}")/../events/emit.sh" 2>/dev/null \
10631
- && _LOKI_EMIT_LIB_LOADED=1
10632
- fi
10633
- if declare -f safe_append_event_jsonl >/dev/null 2>&1; then
10634
- safe_append_event_jsonl ".loki/events.jsonl" "$rb_event" 2>/dev/null || true
10635
- else
10636
- # Last-resort fallback: bare append (preserves prior behavior).
10637
- printf '%s\n' "$rb_event" >> ".loki/events.jsonl" 2>/dev/null || true
10638
- fi
10639
- fi
10640
-
10641
- log_info "State files restored from checkpoint: ${checkpoint_id}"
10642
9745
 
10643
- # R6: the prior hint `git reset --hard ${git_sha}` was MISLEADING. git_sha is
10644
- # HEAD (the last commit), and Loki does not commit per iteration, so a hard
10645
- # reset would discard the iteration's work rather than reconstruct it. The
10646
- # correct, durable recovery is the anchored working-tree snapshot, if present.
10647
- if [ -f "${cp_dir}/worktree-snapshot.txt" ]; then
10648
- log_info "To also restore the working tree to this checkpoint:"
10649
- log_info " git stash apply refs/loki/cp/${checkpoint_id}"
10650
- elif [ -n "$git_sha" ] && [ "$git_sha" != "no-git" ]; then
10651
- log_info "Git SHA at checkpoint (last commit): ${git_sha}"
10652
- log_info "Note: no working-tree snapshot was captured for this checkpoint;"
10653
- log_info "code changes since the last commit are not restorable from here."
10654
- fi
9746
+ # A3: best-effort object-store sync of checkpoint state. No-op unless
9747
+ # LOKI_STORAGE_BACKEND is set to a non-local backend. Never blocks/fails the
9748
+ # build on a sync error (the shim swallows errors; we also `|| true`).
9749
+ _loki_object_store_sync_checkpoints || true
10655
9750
  }
10656
9751
 
10657
- list_checkpoints() {
10658
- # List recent checkpoints
10659
- local checkpoint_dir=".loki/state/checkpoints"
10660
- local index_file="${checkpoint_dir}/index.jsonl"
10661
- local limit="${1:-10}"
9752
+ # A3: push .loki/state/checkpoints/** to the configured object store (best-effort).
9753
+ # Local/unset backend => no-op (zero behavior change for local users). Provider
9754
+ # writes are out of scope; this syncs only the checkpoint state run.sh owns.
9755
+ _loki_object_store_sync_checkpoints() {
9756
+ local backend="${LOKI_STORAGE_BACKEND:-local}"
9757
+ case "$backend" in
9758
+ local|""|file|filesystem) return 0 ;;
9759
+ esac
9760
+ command -v python3 >/dev/null 2>&1 || return 0
9761
+ local shim="${SCRIPT_DIR}/lib/checkpoint_sync.py"
9762
+ [ -f "$shim" ] || return 0
9763
+ python3 "$shim" sync >/dev/null 2>&1 || log_warn "Object-store checkpoint sync skipped (backend=$backend); build continues."
9764
+ return 0
9765
+ }
10662
9766
 
10663
- if [ ! -f "$index_file" ]; then
10664
- echo "No checkpoints found."
10665
- return
9767
+ # A3: hydrate .loki/state/checkpoints/** from the object store on a durable
9768
+ # resume when the local volume is empty (best-effort). No-op unless
9769
+ # LOKI_DURABLE_STATE=1 AND a non-local LOKI_STORAGE_BACKEND is set. The shim
9770
+ # itself guards against overwriting a non-empty local volume.
9771
+ _loki_object_store_hydrate_checkpoints() {
9772
+ [ "${LOKI_DURABLE_STATE:-0}" = "1" ] || return 0
9773
+ local backend="${LOKI_STORAGE_BACKEND:-local}"
9774
+ case "$backend" in
9775
+ local|""|file|filesystem) return 0 ;;
9776
+ esac
9777
+ command -v python3 >/dev/null 2>&1 || return 0
9778
+ local shim="${SCRIPT_DIR}/lib/checkpoint_sync.py"
9779
+ [ -f "$shim" ] || return 0
9780
+ if python3 "$shim" hydrate >/dev/null 2>&1; then
9781
+ log_info "Durable resume: checked object store ($backend) for prior checkpoints."
9782
+ else
9783
+ log_warn "Object-store checkpoint hydrate skipped (backend=$backend); build continues with local state."
10666
9784
  fi
10667
-
10668
- tail -n "$limit" "$index_file" | python3 -c "
10669
- import sys, json
10670
- lines = sys.stdin.readlines()
10671
- for line in reversed(lines):
10672
- try:
10673
- cp = json.loads(line)
10674
- sha = cp.get('sha','')[:8]
10675
- task = cp.get('task','')[:60]
10676
- print(f\" {cp['id']} {cp['ts']} [{sha}] {task}\")
10677
- except:
10678
- continue
10679
- "
9785
+ return 0
10680
9786
  }
10681
9787
 
10682
9788
  start_dashboard() {
@@ -11928,17 +11034,6 @@ except Exception as e:
11928
11034
  }
11929
11035
 
11930
11036
  # Load relevant learnings
11931
- load_learnings_context() {
11932
- local learnings=""
11933
-
11934
- # Get recent learnings (last 7 days)
11935
- for learning in $(find .loki/memory/learnings -name "*.md" -mtime -7 2>/dev/null | head -5); do
11936
- learnings+="$(head -30 "$learning")\n---\n"
11937
- done
11938
-
11939
- echo -e "$learnings"
11940
- }
11941
-
11942
11037
  # Load pre-computed relevant learnings from CLI startup (SYN-008)
11943
11038
  # Reads .loki/state/memory-context.json written by load_memory_context() in CLI
11944
11039
  # Note: Different from get_relevant_learnings() which writes to relevant-learnings.json
@@ -12700,89 +11795,44 @@ PYEOF
12700
11795
  #===============================================================================
12701
11796
 
12702
11797
  # Enrich prompt context with relevant cross-project patterns
12703
- enrich_from_knowledge_graph() {
12704
- local context="$1"
12705
- local max_patterns="${2:-5}"
12706
-
12707
- _LOKI_KG_CONTEXT="$context" _LOKI_KG_MAX="$max_patterns" \
12708
- _LOKI_PROJECT_DIR="$PROJECT_DIR" \
12709
- python3 << 'PYEOF' 2>/dev/null || echo ""
12710
- import sys
12711
- import os
12712
- import json
12713
-
12714
- project_dir = os.environ.get('_LOKI_PROJECT_DIR', '')
12715
- context = os.environ.get('_LOKI_KG_CONTEXT', '')
12716
- max_results = int(os.environ.get('_LOKI_KG_MAX', '5'))
12717
-
12718
- if not project_dir:
12719
- sys.exit(0)
12720
- sys.path.insert(0, project_dir)
12721
- try:
12722
- from memory.knowledge_graph import OrganizationKnowledgeGraph
12723
- kg = OrganizationKnowledgeGraph()
12724
- patterns = kg.query_patterns(context, max_results=max_results)
12725
- if patterns:
12726
- output = "\n## Cross-Project Knowledge (from knowledge graph)\n"
12727
- for p in patterns:
12728
- name = p.get('name', p.get('pattern', 'unnamed'))
12729
- category = p.get('category', '')
12730
- desc = p.get('description', '')
12731
- output += f"- **{name}** ({category}): {desc}\n"
12732
- print(output)
12733
- except Exception:
12734
- pass
12735
- PYEOF
12736
- }
12737
-
12738
11798
  # Store new patterns to the knowledge graph after successful iterations
12739
- store_to_knowledge_graph() {
12740
- local target_dir="${TARGET_DIR:-.}"
12741
-
12742
- _LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \
12743
- python3 << 'PYEOF' 2>/dev/null || true
12744
- import sys
12745
- import os
12746
-
12747
- project_dir = os.environ.get('_LOKI_PROJECT_DIR', '')
12748
- target_dir = os.environ.get('_LOKI_TARGET_DIR', '.')
12749
-
12750
- sys.path.insert(0, project_dir)
12751
- try:
12752
- from memory.knowledge_graph import OrganizationKnowledgeGraph
12753
- from pathlib import Path
12754
-
12755
- kg = OrganizationKnowledgeGraph()
12756
- project_dirs = [Path(target_dir)]
12757
-
12758
- # Extract and store patterns
12759
- patterns = kg.extract_patterns(project_dirs)
12760
- if patterns:
12761
- patterns = kg.deduplicate_patterns(patterns)
12762
- kg.save_patterns(patterns)
12763
-
12764
- # Rebuild graph
12765
- kg.build_graph(project_dirs)
12766
- kg.save_graph()
12767
- except Exception:
12768
- pass
12769
- PYEOF
12770
- }
12771
-
12772
11799
  #===============================================================================
12773
11800
  # Save/Load Wrapper State
12774
11801
  #===============================================================================
12775
11802
 
11803
+ # A6 (multi-build-per-pod state isolation): resolve the autonomy-state.json path.
11804
+ # The existing .loki/sessions/<id>/ scoping (v6.4.0) namespaces PID + lock files
11805
+ # so concurrent sessions in the SAME TARGET_DIR do not collide. autonomy-state.json
11806
+ # was NOT covered by that scoping, so two concurrent builds with distinct
11807
+ # LOKI_SESSION_IDs still raced on the one global .loki/autonomy-state.json
11808
+ # (lost-update on retry/iteration counts). When LOKI_SESSION_ID is set we mirror
11809
+ # the session scoping and put the file under .loki/sessions/<id>/. When unset
11810
+ # (the normal `loki start ./prd.md` case) the legacy .loki/autonomy-state.json
11811
+ # path is returned UNCHANGED, so default single-session behavior is byte-identical.
11812
+ _loki_state_file() {
11813
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
11814
+ printf '%s' ".loki/sessions/${LOKI_SESSION_ID}/autonomy-state.json"
11815
+ else
11816
+ printf '%s' ".loki/autonomy-state.json"
11817
+ fi
11818
+ }
11819
+
12776
11820
  save_state() {
12777
11821
  local retry_count="$1"
12778
11822
  local status="$2"
12779
11823
  local exit_code="$3"
12780
11824
 
12781
- # BUG-ST-013: Ensure .loki directory exists (defensive -- may be called from signal handler)
12782
- mkdir -p .loki 2>/dev/null || true
11825
+ local state_file
11826
+ state_file="$(_loki_state_file)"
11827
+
11828
+ # BUG-ST-013: Ensure target directory exists (defensive -- may be called from
11829
+ # signal handler). For a namespaced session path this also creates
11830
+ # .loki/sessions/<id>/; for the default path it is .loki/ as before.
11831
+ mkdir -p "$(dirname "$state_file")" 2>/dev/null || true
12783
11832
 
12784
- # BUG-XC-004: Atomic write via temp file + mv
12785
- local state_tmp=".loki/autonomy-state.json.tmp.$$"
11833
+ # BUG-XC-004: Atomic write via temp file + mv (temp lives beside the target so
11834
+ # the rename stays on the same filesystem)
11835
+ local state_tmp="${state_file}.tmp.$$"
12786
11836
  cat > "$state_tmp" << EOF
12787
11837
  {
12788
11838
  "retryCount": $retry_count,
@@ -12796,23 +11846,30 @@ save_state() {
12796
11846
  "baseWait": $BASE_WAIT
12797
11847
  }
12798
11848
  EOF
12799
- mv -f "$state_tmp" ".loki/autonomy-state.json"
11849
+ mv -f "$state_tmp" "$state_file"
12800
11850
  }
12801
11851
 
12802
11852
  load_state() {
11853
+ local state_file
11854
+ state_file="$(_loki_state_file)"
11855
+
12803
11856
  # BUG-EP-015: Clean up orphaned temp files from kill -9 crashes
12804
11857
  # These are left behind when the process is killed during atomic writes
12805
11858
  find .loki/ -maxdepth 1 -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
12806
11859
  find .loki/state/ -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
11860
+ # A6: also sweep namespaced session state temps when running under a session id
11861
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
11862
+ find ".loki/sessions/${LOKI_SESSION_ID}/" -maxdepth 1 -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
11863
+ fi
12807
11864
 
12808
- if [ -f ".loki/autonomy-state.json" ]; then
11865
+ if [ -f "$state_file" ]; then
12809
11866
  if command -v python3 &> /dev/null; then
12810
11867
  # BUG-ST-006: Validate checkpoint integrity before loading state
12811
11868
  local state_valid
12812
- state_valid=$(python3 -c "
12813
- import json, sys
11869
+ state_valid=$(LOKI_STATE_FILE="$state_file" python3 -c "
11870
+ import json, os, sys
12814
11871
  try:
12815
- with open('.loki/autonomy-state.json') as f:
11872
+ with open(os.environ['LOKI_STATE_FILE']) as f:
12816
11873
  d = json.load(f)
12817
11874
  # Validate required fields exist and have sane types
12818
11875
  rc = d.get('retryCount', 0)
@@ -12834,16 +11891,16 @@ except (json.JSONDecodeError, KeyError, TypeError, OSError):
12834
11891
  RETRY_COUNT=0
12835
11892
  ITERATION_COUNT=0
12836
11893
  # Back up corrupted state file for diagnosis
12837
- mv ".loki/autonomy-state.json" ".loki/autonomy-state.json.corrupt.$(date +%s)" 2>/dev/null || true
11894
+ mv "$state_file" "${state_file}.corrupt.$(date +%s)" 2>/dev/null || true
12838
11895
  return
12839
11896
  fi
12840
11897
 
12841
11898
  # Load retry count, iteration count, and status from previous session
12842
11899
  local prev_status
12843
- prev_status=$(python3 -c "import json; print(json.load(open('.loki/autonomy-state.json')).get('status', 'unknown'))" 2>/dev/null || echo "unknown")
12844
- RETRY_COUNT=$(python3 -c "import json; print(json.load(open('.loki/autonomy-state.json')).get('retryCount', 0))" 2>/dev/null || echo "0")
11900
+ prev_status=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status', 'unknown'))" 2>/dev/null || echo "unknown")
11901
+ RETRY_COUNT=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('retryCount', 0))" 2>/dev/null || echo "0")
12845
11902
  # BUG-RUN-003: Restore ITERATION_COUNT from persisted state
12846
- ITERATION_COUNT=$(python3 -c "import json; print(json.load(open('.loki/autonomy-state.json')).get('iterationCount', 0))" 2>/dev/null || echo "0")
11903
+ ITERATION_COUNT=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('iterationCount', 0))" 2>/dev/null || echo "0")
12847
11904
 
12848
11905
  # Reset retry count + iteration count if previous session ended in a
12849
11906
  # terminal state. A fresh `loki start` after a terminal run is a NEW
@@ -14945,7 +14002,7 @@ except Exception as exc:
14945
14002
 
14946
14003
  # Check max iterations before starting
14947
14004
  if check_max_iterations; then
14948
- log_error "Max iterations already reached. Reset with: rm .loki/autonomy-state.json"
14005
+ log_error "Max iterations already reached. Reset with: rm $(_loki_state_file)"
14949
14006
  # Delegate-then-notify: terminal state. Mirror the in-loop max-iterations
14950
14007
  # site so a detached (--bg) run still writes COMPLETION.txt + fires the
14951
14008
  # ping on this pre-loop exit. _LOKI_RUN_START_SHA is already exported
@@ -17784,6 +16841,14 @@ main() {
17784
16841
  # durable mount, so a misconfigured volume never silently loses build state.
17785
16842
  assert_durable_state_mount
17786
16843
 
16844
+ # A3: on a durable resume with an object-store backend, hydrate this run's
16845
+ # checkpoints from the store IF the local volume came up empty (a fresh pod /
16846
+ # non-durable volume). Best-effort + idempotent: the shim no-ops when the
16847
+ # backend is local, when the local volume already has checkpoints, or when
16848
+ # the store has nothing for this run. Runs before the main loop so any
16849
+ # resume/rollback sees the restored state. Zero behavior change for local.
16850
+ _loki_object_store_hydrate_checkpoints || true
16851
+
17787
16852
  # Setup agent branch protection (isolates agent changes to a feature branch)
17788
16853
  setup_agent_branch
17789
16854
 
@@ -18001,8 +17066,9 @@ main() {
18001
17066
  # because the process was SIGKILLed before reaching here). Retryable;
18002
17067
  # the restarted Job resumes via the ENT-2 durable-resume path.
18003
17068
  if [ "${LOKI_DURABLE_STATE:-0}" = "1" ]; then
18004
- local _final_status
18005
- _final_status=$(python3 -c "import json; print(json.load(open('.loki/autonomy-state.json')).get('status','unknown'))" 2>/dev/null || echo "unknown")
17069
+ local _final_status _final_state_file
17070
+ _final_state_file="$(_loki_state_file)"
17071
+ _final_status=$(LOKI_STATE_FILE="$_final_state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status','unknown'))" 2>/dev/null || echo "unknown")
18006
17072
  case "$_final_status" in
18007
17073
  council_approved|council_force_approved|completion_promise_fulfilled|force_stopped|paused|interrupted|budget_exceeded|stopped)
18008
17074
  result=0 ;;