loki-mode 7.66.1 → 7.67.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 CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.66.1
6
+ # Loki Mode v7.67.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
406
406
 
407
407
  ---
408
408
 
409
- **v7.66.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.67.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.66.1
1
+ 7.67.0
@@ -778,7 +778,12 @@ app_runner_init() {
778
778
  local _project_hash
779
779
  _project_hash=$(echo "$dir" | (md5sum 2>/dev/null || md5 -r 2>/dev/null || echo "$$") | cut -c1-8)
780
780
  _APP_RUNNER_DOCKER_CONTAINER="loki-app-${_project_hash}"
781
- _APP_RUNNER_METHOD="docker build -t loki-app . && docker run -d -p ${_APP_RUNNER_PORT}:${_APP_RUNNER_PORT} --name ${_APP_RUNNER_DOCKER_CONTAINER} loki-app"
781
+ # Hash the image tag the same way the container name is hashed so two
782
+ # Dockerfile-based projects do not clobber each other's image (an
783
+ # unhashed `loki-app` tag would be shared across every project). Build
784
+ # tag and run image arg MUST stay identical.
785
+ local _image_tag="loki-app-${_project_hash}"
786
+ _APP_RUNNER_METHOD="docker build -t ${_image_tag} . && docker run -d -p ${_APP_RUNNER_PORT}:${_APP_RUNNER_PORT} --name ${_APP_RUNNER_DOCKER_CONTAINER} ${_image_tag}"
782
787
  _APP_RUNNER_IS_DOCKER=true
783
788
  _write_detection "dockerfile" "$_APP_RUNNER_METHOD"
784
789
  log_info "App Runner: detected Dockerfile"
@@ -1012,6 +1017,59 @@ _app_runner_compose_running_count() {
1012
1017
  return 0
1013
1018
  }
1014
1019
 
1020
+ # Decide whether to prepend `exec` to the launched method. `exec` replaces the
1021
+ # bash wrapper with the command so the captured PID is the app itself (PID
1022
+ # identity for npm start / python app.py etc.). That is ONLY valid for a SINGLE
1023
+ # command. A compound method like `docker build ... && docker run ...` must NOT
1024
+ # be exec'd: `exec docker build` would replace the shell and the `&& docker run`
1025
+ # half would never run (the verified HIGH-1 bug -- image builds, no container).
1026
+ # Detection runs on the METHOD STRING ONLY, never the assembled launch line: the
1027
+ # assembled line always contains `;` (from the PORT env prefix and the pgid
1028
+ # `echo $$`), so testing it would mark every method compound and silently drop
1029
+ # the exec optimization for single commands.
1030
+ # Echoes "exec " for a single command, or "" (empty) for a compound command.
1031
+ _app_runner_exec_prefix() {
1032
+ local method="$1"
1033
+ case "$method" in
1034
+ *"&&"*|*"||"*|*";"*)
1035
+ # Compound: let bash run the full sequence as a child (no exec).
1036
+ printf '%s' ""
1037
+ ;;
1038
+ *)
1039
+ printf '%s' "exec "
1040
+ ;;
1041
+ esac
1042
+ }
1043
+
1044
+ # Liveness predicate for the Dockerfile (single-image `docker run -d`) path,
1045
+ # which -- unlike compose -- has a project-hashed container name in
1046
+ # $_APP_RUNNER_DOCKER_CONTAINER. The method is a compound `docker build && docker
1047
+ # run -d` launched WITHOUT exec, so the captured PID is the short-lived bash
1048
+ # wrapper: it stays alive for the (possibly multi-minute) build, then exits right
1049
+ # after `docker run -d` detaches. Therefore liveness is:
1050
+ # alive = container running OR wrapper PID still alive (build in progress)
1051
+ # dead = wrapper PID dead AND container not running
1052
+ # This tolerates a slow-but-succeeding build while a genuinely broken Dockerfile
1053
+ # still trips the watchdog breaker (wrapper dies, no container, 5x). Returns 0
1054
+ # when alive, 1 when dead. Never hard-fails (guarded for set -u / future set -e).
1055
+ _app_runner_dockerfile_container_running() {
1056
+ local _name="${_APP_RUNNER_DOCKER_CONTAINER:-}"
1057
+ [ -z "$_name" ] && return 1
1058
+ if command -v docker >/dev/null 2>&1; then
1059
+ local _state
1060
+ _state=$(docker inspect -f '{{.State.Running}}' "$_name" 2>/dev/null || true)
1061
+ if [ "$_state" = "true" ]; then
1062
+ return 0
1063
+ fi
1064
+ fi
1065
+ # Container not (yet) running: the build may still be in progress. The wrapper
1066
+ # PID being alive is the build-in-progress signal.
1067
+ if [ -n "${_APP_RUNNER_PID:-}" ] && kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
1068
+ return 0
1069
+ fi
1070
+ return 1
1071
+ }
1072
+
1015
1073
  # Read the RUNTIME published host port of the identified primary web service from
1016
1074
  # `docker compose ps` (the live mapping), as opposed to the config-declared port
1017
1075
  # from `docker compose config`. The config port is correct for fixed mappings
@@ -1127,6 +1185,25 @@ app_runner_start() {
1127
1185
  _port_env_prefix="export PORT=$_APP_RUNNER_PORT HTTP_PORT=$_APP_RUNNER_PORT SERVER_PORT=$_APP_RUNNER_PORT APP_PORT=$_APP_RUNNER_PORT; "
1128
1186
  fi
1129
1187
 
1188
+ # Conditional exec (HIGH-1 fix): only `exec` a SINGLE command. A compound
1189
+ # method (`docker build ... && docker run ...`) must run as a child so BOTH
1190
+ # halves execute -- `exec docker build` would replace the shell and never
1191
+ # reach `&& docker run`. Computed on the method string ONLY (see
1192
+ # _app_runner_exec_prefix), not the assembled launch line.
1193
+ local _exec_prefix
1194
+ _exec_prefix=$(_app_runner_exec_prefix "$_APP_RUNNER_METHOD")
1195
+
1196
+ # Dockerfile path: `docker run --name <hashed>` fails if a stale (exited)
1197
+ # container with that name still exists. This happens on a watchdog restart
1198
+ # (the prior run's container was stopped, not removed) and would make every
1199
+ # auto-restart fail with "name already in use". Remove any stale container
1200
+ # by name before launch. Idempotent and safe when none exists. Compose has no
1201
+ # _APP_RUNNER_DOCKER_CONTAINER, so this is Dockerfile-path only.
1202
+ if [ "$_APP_RUNNER_IS_DOCKER" = true ] && [ -n "${_APP_RUNNER_DOCKER_CONTAINER:-}" ] \
1203
+ && command -v docker >/dev/null 2>&1; then
1204
+ docker rm -f "$_APP_RUNNER_DOCKER_CONTAINER" >/dev/null 2>&1 || true
1205
+ fi
1206
+
1130
1207
  # Start the process in a new process group
1131
1208
  if command -v setsid >/dev/null 2>&1; then
1132
1209
  _APP_RUNNER_HAS_SETSID=true
@@ -1136,7 +1213,7 @@ app_runner_start() {
1136
1213
  # Note: $_APP_RUNNER_METHOD has passed _validate_app_command (whitelist).
1137
1214
  # The `--` after `bash -lc` prevents flag injection if the assembled
1138
1215
  # script string ever begins with a `-`.
1139
- (cd "$dir" && setsid bash -lc -- "$_port_env_prefix"'echo $$ > "'"$_pgid_file"'"; exec '"$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
1216
+ (cd "$dir" && setsid bash -lc -- "$_port_env_prefix"'echo $$ > "'"$_pgid_file"'"; '"$_exec_prefix$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
1140
1217
  local _subshell_pid=$!
1141
1218
  # Wait briefly for the pgid file to appear, then read the real PGID
1142
1219
  local _pgid_wait=0
@@ -1154,7 +1231,7 @@ app_runner_start() {
1154
1231
  _APP_RUNNER_HAS_SETSID=false
1155
1232
  # Note: $_APP_RUNNER_METHOD has passed _validate_app_command (whitelist).
1156
1233
  # The `--` after `bash -lc` prevents flag injection.
1157
- (cd "$dir" && bash -lc -- "${_port_env_prefix}exec $_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
1234
+ (cd "$dir" && bash -lc -- "${_port_env_prefix}${_exec_prefix}$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
1158
1235
  _APP_RUNNER_PID=$!
1159
1236
  fi
1160
1237
  # Register with central PID registry if available
@@ -1212,6 +1289,24 @@ app_runner_start() {
1212
1289
  _write_app_state "failed"
1213
1290
  return 1
1214
1291
  fi
1292
+ elif [ "$_APP_RUNNER_IS_DOCKER" = true ] && [ -n "${_APP_RUNNER_DOCKER_CONTAINER:-}" ]; then
1293
+ # Dockerfile path (HIGH-1): `docker build && docker run -d` is compound, so
1294
+ # it is launched WITHOUT exec and the captured PID is the short-lived bash
1295
+ # wrapper that exits once the detached container is up. Liveness keys on the
1296
+ # container (or the wrapper still building), NOT the wrapper PID -- the same
1297
+ # reasoning as the compose branch above. Port mapping is the fixed
1298
+ # `-p PORT:PORT` from detection and the URL is already set, so no port
1299
+ # reconciliation or PID identity token is needed here.
1300
+ if _app_runner_dockerfile_container_running; then
1301
+ _write_app_state "running"
1302
+ log_info "App Runner: Dockerfile container '$_APP_RUNNER_DOCKER_CONTAINER' starting/running on port $_APP_RUNNER_PORT"
1303
+ return 0
1304
+ else
1305
+ log_error "App Runner: Dockerfile container failed to start (no running container, build wrapper exited)"
1306
+ _APP_RUNNER_CRASH_COUNT=$(( _APP_RUNNER_CRASH_COUNT + 1 ))
1307
+ _write_app_state "failed"
1308
+ return 1
1309
+ fi
1215
1310
  elif kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
1216
1311
  # Reconcile recorded port with the port the app actually bound (finding
1217
1312
  # #597), so state.json / detection.json / the preview URL point at the
@@ -1456,6 +1551,23 @@ app_runner_health_check() {
1456
1551
  return 0
1457
1552
  fi
1458
1553
 
1554
+ # Dockerfile path (HIGH-1): the detached `docker run -d` container's liveness
1555
+ # is the container running (or the build wrapper still building), NOT the
1556
+ # ephemeral bash wrapper PID. Without this branch the wrapper PID dies after
1557
+ # the build detaches the container and the PID check below would report the
1558
+ # live container as crashed -> watchdog tears it down and rebuilds forever.
1559
+ if [ "$_APP_RUNNER_IS_DOCKER" = true ] && [ -n "${_APP_RUNNER_DOCKER_CONTAINER:-}" ]; then
1560
+ if _app_runner_dockerfile_container_running; then
1561
+ _write_health "true"
1562
+ _write_app_state "running"
1563
+ return 0
1564
+ else
1565
+ _write_health "false"
1566
+ _write_app_state "crashed"
1567
+ return 1
1568
+ fi
1569
+ fi
1570
+
1459
1571
  # Check PID is alive (non-docker-compose methods)
1460
1572
  if ! kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
1461
1573
  _write_health "false"
@@ -1580,7 +1692,16 @@ app_runner_watchdog() {
1580
1692
  # This is what makes the service-aware health logic actually fire in the
1581
1693
  # live monitoring loop (not just in isolation). On an unhealthy web service
1582
1694
  # it restarts the stack under the same crash-count circuit breaker.
1583
- if [ "$_APP_RUNNER_IS_DOCKER" = true ] && echo "$_APP_RUNNER_METHOD" | grep -q "docker compose"; then
1695
+ # Detached-docker paths (compose stacks AND the Dockerfile `docker run -d`
1696
+ # container) both exit their captured wrapper PID once the container is up, so
1697
+ # `kill -0` is the wrong liveness signal. Delegate to app_runner_health_check,
1698
+ # whose container-aware branches (compose web service / hashed Dockerfile
1699
+ # container) own the real liveness check, under the same crash-count circuit
1700
+ # breaker. Without including the Dockerfile container here, the wrapper PID
1701
+ # would read as dead after the build detaches and the watchdog would tear the
1702
+ # live container down and rebuild forever (the HIGH-1 symptom).
1703
+ if [ "$_APP_RUNNER_IS_DOCKER" = true ] && \
1704
+ { echo "$_APP_RUNNER_METHOD" | grep -q "docker compose" || [ -n "${_APP_RUNNER_DOCKER_CONTAINER:-}" ]; }; then
1584
1705
  if app_runner_health_check; then
1585
1706
  # BUG 3 fix: the breaker is meant to fire on 5 CONSECUTIVE failures.
1586
1707
  # A confirmed-healthy observation clears any accumulated count so a
@@ -1590,7 +1711,7 @@ app_runner_watchdog() {
1590
1711
  return 0
1591
1712
  fi
1592
1713
  _APP_RUNNER_CRASH_COUNT=$(( _APP_RUNNER_CRASH_COUNT + 1 ))
1593
- log_warn "App Runner: compose web service unhealthy (crash #$_APP_RUNNER_CRASH_COUNT)"
1714
+ log_warn "App Runner: docker container unhealthy (crash #$_APP_RUNNER_CRASH_COUNT)"
1594
1715
  if [ "$_APP_RUNNER_CRASH_COUNT" -ge 5 ]; then
1595
1716
  log_error "App Runner: crash limit reached (5), marking as crashed"
1596
1717
  tail -20 "$_APP_RUNNER_DIR/app.log" 2>/dev/null | while IFS= read -r line; do
@@ -1601,9 +1722,9 @@ app_runner_watchdog() {
1601
1722
  fi
1602
1723
  local _c_backoff=$(( 1 << _APP_RUNNER_CRASH_COUNT ))
1603
1724
  [ "$_c_backoff" -gt 30 ] && _c_backoff=30
1604
- log_info "App Runner: restarting compose stack in ${_c_backoff}s..."
1725
+ log_info "App Runner: restarting docker app in ${_c_backoff}s..."
1605
1726
  sleep "$_c_backoff"
1606
- app_runner_start || log_warn "App Runner: compose auto-restart failed"
1727
+ app_runner_start || log_warn "App Runner: docker auto-restart failed"
1607
1728
  return 0
1608
1729
  fi
1609
1730
 
package/autonomy/loki CHANGED
@@ -2204,6 +2204,72 @@ _kill_pid() {
2204
2204
  fi
2205
2205
  }
2206
2206
 
2207
+ # v7.7.34 group-kill, factored (loki-stop-F1). Reaps the orchestrator's whole
2208
+ # process group via the recorded pgid so the autonomous agent (claude/codex/
2209
+ # aider), which shares the orchestrator group and would otherwise reparent to
2210
+ # init and keep editing files, is killed atomically. This is the SAME logic the
2211
+ # no-arg `loki stop` path used inline; it is now shared so the by-id path
2212
+ # (`loki stop <session-id>`) gets identical reaping instead of skipping it.
2213
+ #
2214
+ # Args: one or more pgid-file paths. Each is read, validated (numeric, > 1, NOT
2215
+ # this shell's own group), and group-killed; the file is removed after. A
2216
+ # protected-pid conflict (dashboard / app-runner / registered pids that happen
2217
+ # to share the group) downgrades the kill to a per-pid kill that excludes the
2218
+ # protected pids, so a group-kill never tears down the dashboard. Every kill is
2219
+ # `|| true` guarded -- safe under set -e (killing an already-dead member, or an
2220
+ # empty group, returns non-zero legitimately).
2221
+ _stop_group_by_pgid_files() {
2222
+ local _stop_pgid_file
2223
+ for _stop_pgid_file in "$@"; do
2224
+ [ -f "$_stop_pgid_file" ] || continue
2225
+ local _spgid
2226
+ _spgid=$(cat "$_stop_pgid_file" 2>/dev/null | tr -d ' ')
2227
+ case "$_spgid" in ''|*[!0-9]*) continue ;; esac
2228
+ [ "$_spgid" -gt 1 ] 2>/dev/null || continue
2229
+ local _my_pgid
2230
+ _my_pgid=$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')
2231
+ [ "$_spgid" = "$_my_pgid" ] && continue # never kill our own group
2232
+ # Collect protected pids (dashboard, app-runner, registered pids) so
2233
+ # a group-kill never takes down the dashboard if it happens to share
2234
+ # the orchestrator group. Mirrors the dashboard Python route.
2235
+ local _protected=" "
2236
+ local _pf
2237
+ if [ -d "$LOKI_DIR/pids" ]; then
2238
+ for _pf in "$LOKI_DIR/pids"/*.json; do
2239
+ [ -f "$_pf" ] || continue
2240
+ _protected="${_protected}$(basename "$_pf" .json) "
2241
+ done
2242
+ fi
2243
+ for _pf in "$LOKI_DIR/dashboard/dashboard.pid" "${HOME}/.loki/dashboard/dashboard.pid"; do
2244
+ [ -f "$_pf" ] && _protected="${_protected}$(cat "$_pf" 2>/dev/null | tr -d ' ') "
2245
+ done
2246
+ # Does any protected pid share this group?
2247
+ local _conflict=0 _gp
2248
+ for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_spgid" '$2==g{print $1}'); do
2249
+ case "$_protected" in *" $_gp "*) _conflict=1; break ;; esac
2250
+ done
2251
+ if [ "$_conflict" = "1" ]; then
2252
+ # Per-pid kill of group members EXCLUDING protected pids.
2253
+ for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_spgid" '$2==g{print $1}'); do
2254
+ case "$_protected" in *" $_gp "*) continue ;; esac
2255
+ [ "$_gp" = "$$" ] && continue
2256
+ kill -TERM "$_gp" 2>/dev/null || true
2257
+ done
2258
+ sleep 1
2259
+ for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_spgid" '$2==g{print $1}'); do
2260
+ case "$_protected" in *" $_gp "*) continue ;; esac
2261
+ [ "$_gp" = "$$" ] && continue
2262
+ kill -KILL "$_gp" 2>/dev/null || true
2263
+ done
2264
+ else
2265
+ kill -TERM -- -"$_spgid" 2>/dev/null || true
2266
+ sleep 1
2267
+ kill -KILL -- -"$_spgid" 2>/dev/null || true
2268
+ fi
2269
+ rm -f "$_stop_pgid_file" 2>/dev/null || true
2270
+ done
2271
+ }
2272
+
2207
2273
  # Stop a specific session by its session ID
2208
2274
  _stop_session_by_id() {
2209
2275
  local sid="$1"
@@ -2296,6 +2362,29 @@ cmd_stop() {
2296
2362
  # Stop a specific session by ID
2297
2363
  if [ -n "$target_session" ]; then
2298
2364
  if is_session_running "$target_session"; then
2365
+ # loki-stop-F1: group-kill FIRST (v7.7.34 discipline), scoped to THIS
2366
+ # session's recorded pgid. Without this the by-id path reaped only the
2367
+ # orchestrator pid (via _stop_session_by_id -> _kill_pid), leaving the
2368
+ # autonomous agent (claude/codex/aider) -- which shares the
2369
+ # orchestrator's process group -- to reparent to init and keep editing
2370
+ # files. That is exactly the v7.7.34 orphaned-agent bug, reopened on
2371
+ # the by-id path. The pgid is session-scoped: run.sh writes it next to
2372
+ # the session pid as ${pid_file%.pid}.pgid, so we only ever touch THIS
2373
+ # session's group (modern sessions/<id>/loki.pgid + legacy
2374
+ # run-<id>.pgid), never a sibling session or another folder.
2375
+ #
2376
+ # Deliberately NOT mirrored from the no-arg path: the docker reap,
2377
+ # session.json->stopped, and dashboard registry mark are folder/global
2378
+ # side effects (the docker container is named by workspace sha with no
2379
+ # per-session container; registry.mark_project_stopped marks the whole
2380
+ # project; session.json is the folder-level skill session). Firing them
2381
+ # on a by-id stop would mismark the project / kill a docker run while a
2382
+ # sibling session in the same folder is still building. The group-kill
2383
+ # alone closes the stated orphaned-agent hole; folder-global teardown
2384
+ # stays on the no-arg / --all paths.
2385
+ _stop_group_by_pgid_files \
2386
+ "$LOKI_DIR/sessions/$target_session/loki.pgid" \
2387
+ "$LOKI_DIR/run-${target_session}.pgid"
2299
2388
  _stop_session_by_id "$target_session"
2300
2389
  echo "Stopped session: $target_session"
2301
2390
  else
@@ -2381,56 +2470,9 @@ cmd_stop() {
2381
2470
  # session leader), so signaling the whole group reaps the orchestrator
2382
2471
  # AND the agent child atomically. Killing only the orchestrator pid lets
2383
2472
  # the agent reparent to init and keep editing files -- the reported bug.
2384
- # Guards: only a numeric pgid > 1 that is NOT this shell's own group.
2385
- local _stop_pgid_file
2386
- for _stop_pgid_file in "$LOKI_DIR/loki.pgid" "$LOKI_DIR/run.pgid"; do
2387
- [ -f "$_stop_pgid_file" ] || continue
2388
- local _spgid
2389
- _spgid=$(cat "$_stop_pgid_file" 2>/dev/null | tr -d ' ')
2390
- case "$_spgid" in ''|*[!0-9]*) continue ;; esac
2391
- [ "$_spgid" -gt 1 ] 2>/dev/null || continue
2392
- local _my_pgid
2393
- _my_pgid=$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')
2394
- [ "$_spgid" = "$_my_pgid" ] && continue # never kill our own group
2395
- # Collect protected pids (dashboard, app-runner, registered pids) so
2396
- # a group-kill never takes down the dashboard if it happens to share
2397
- # the orchestrator group. Mirrors the dashboard Python route.
2398
- local _protected=" "
2399
- local _pf
2400
- if [ -d "$LOKI_DIR/pids" ]; then
2401
- for _pf in "$LOKI_DIR/pids"/*.json; do
2402
- [ -f "$_pf" ] || continue
2403
- _protected="${_protected}$(basename "$_pf" .json) "
2404
- done
2405
- fi
2406
- for _pf in "$LOKI_DIR/dashboard/dashboard.pid" "${HOME}/.loki/dashboard/dashboard.pid"; do
2407
- [ -f "$_pf" ] && _protected="${_protected}$(cat "$_pf" 2>/dev/null | tr -d ' ') "
2408
- done
2409
- # Does any protected pid share this group?
2410
- local _conflict=0 _gp
2411
- for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_spgid" '$2==g{print $1}'); do
2412
- case "$_protected" in *" $_gp "*) _conflict=1; break ;; esac
2413
- done
2414
- if [ "$_conflict" = "1" ]; then
2415
- # Per-pid kill of group members EXCLUDING protected pids.
2416
- for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_spgid" '$2==g{print $1}'); do
2417
- case "$_protected" in *" $_gp "*) continue ;; esac
2418
- [ "$_gp" = "$$" ] && continue
2419
- kill -TERM "$_gp" 2>/dev/null || true
2420
- done
2421
- sleep 1
2422
- for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_spgid" '$2==g{print $1}'); do
2423
- case "$_protected" in *" $_gp "*) continue ;; esac
2424
- [ "$_gp" = "$$" ] && continue
2425
- kill -KILL "$_gp" 2>/dev/null || true
2426
- done
2427
- else
2428
- kill -TERM -- -"$_spgid" 2>/dev/null || true
2429
- sleep 1
2430
- kill -KILL -- -"$_spgid" 2>/dev/null || true
2431
- fi
2432
- rm -f "$_stop_pgid_file" 2>/dev/null || true
2433
- done
2473
+ # Factored into _stop_group_by_pgid_files (loki-stop-F1) so the by-id stop
2474
+ # path performs identical reaping. Here we pass the GLOBAL pgid files.
2475
+ _stop_group_by_pgid_files "$LOKI_DIR/loki.pgid" "$LOKI_DIR/run.pgid"
2434
2476
 
2435
2477
  local killed_pid=""
2436
2478
  for pid_file in "$LOKI_DIR/loki.pid" "$LOKI_DIR/run.pid"; do
package/autonomy/run.sh CHANGED
@@ -1725,12 +1725,25 @@ detect_complexity() {
1725
1725
  # Markdown PRD: count headers and checkboxes
1726
1726
  feature_count=$(grep -c "^##\|^- \[" "$prd_path" 2>/dev/null || echo "0")
1727
1727
  fi
1728
+ # WAVE8 FIX run.sh-provider-F1 (HIGH): grep -c prints "0" AND exits 1 on
1729
+ # zero matches; with the '|| echo "0"' fallback that yields "0\n0", which
1730
+ # crashes the integer tests below ([: 0\n0: integer expression expected)
1731
+ # and silently drops complexity from simple->standard. Strip to digits
1732
+ # after every assignment path (jq, both greps), mirroring file_count:1688.
1733
+ # "0\n0" -> "00" -> arithmetically 0.
1734
+ feature_count="${feature_count:-0}"
1735
+ feature_count="${feature_count//[^0-9]/}"
1736
+ feature_count="${feature_count:-0}"
1728
1737
 
1729
1738
  # Count distinct sections (h2/h3 headers) for structural complexity (#74)
1730
1739
  local section_count=0
1731
1740
  if [[ "$prd_path" != *.json ]]; then
1732
1741
  section_count=$(grep -c "^##\|^###" "$prd_path" 2>/dev/null || echo "0")
1733
1742
  fi
1743
+ # WAVE8 FIX run.sh-provider-F1: same grep -c double-output guard.
1744
+ section_count="${section_count:-0}"
1745
+ section_count="${section_count//[^0-9]/}"
1746
+ section_count="${section_count:-0}"
1734
1747
 
1735
1748
  # PRD complexity uses content length, feature count, AND structural depth (#74)
1736
1749
  # A PRD with multiple sections or substantial content is not "simple" even with few project files
@@ -8927,6 +8940,63 @@ _dispatch_reviewer() {
8927
8940
  esac
8928
8941
  }
8929
8942
 
8943
+ # WAVE8 FIX run.sh-F1/F3 (CRITICAL/HIGH): SAFE-DEFAULT verdict classification.
8944
+ # Given a reviewer file, extract the VERDICT: line (tolerant of leading
8945
+ # markdown like '**VERDICT:**' or '# VERDICT:' so fewer reviewers fall to
8946
+ # NO_VERDICT) and classify it as one of: FAIL, PASS, AMBIGUOUS, NONE.
8947
+ # FAIL -> verdict text contains FAIL/REJECT/BLOCK (verbose suffixes like
8948
+ # "FAIL - [Critical] SQLi", "FAIL.", "FAIL (3 criticals)" all match)
8949
+ # PASS -> verdict text contains PASS/APPROVE (and NOT a fail token); this
8950
+ # preserves the deliberate "PASS with concerns" = pass semantics.
8951
+ # AMBIGUOUS -> a VERDICT: line exists but matches neither (unparseable token).
8952
+ # Callers MUST treat this as non-passing (safe direction), never pass.
8953
+ # NONE -> no parseable VERDICT: line at all (empty / missing).
8954
+ # FAIL-first ordering means a verdict naming both (rare) blocks -- the safe way.
8955
+ # Mirrors the council's _council_parse_vote: parse-miss defaults to the safe
8956
+ # (blocking) direction, never to pass.
8957
+ _classify_verdict() {
8958
+ local file="$1"
8959
+ [ -f "$file" ] && [ -s "$file" ] || { echo "NONE"; return 0; }
8960
+ local verdict
8961
+ # Tolerant anchor: optional leading whitespace, then optional markdown
8962
+ # markers (* # >), then optional whitespace, then VERDICT:. This rescues
8963
+ # '**VERDICT:** FAIL', '# VERDICT: PASS', '> VERDICT: FAIL' that the strict
8964
+ # '^VERDICT:' anchor missed (those previously became NO_VERDICT and dropped
8965
+ # the reviewer's dissent).
8966
+ verdict=$(grep -iE "^[[:space:]]*[*#>]*[[:space:]]*VERDICT:" "$file" \
8967
+ | head -1 \
8968
+ | sed -E 's/^[[:space:]]*[*#>]*[[:space:]]*[Vv][Ee][Rr][Dd][Ii][Cc][Tt]:[*[:space:]]*//' \
8969
+ | tr '[:lower:]' '[:upper:]')
8970
+ # Classify on the FIRST verdict TOKEN only, not a substring scan of the whole
8971
+ # despaced line. A whole-line scan is asymmetric and wrong: "PASS, no failures
8972
+ # found" or "PASS - no blocking issues" contain FAIL/BLOCK as substrings and
8973
+ # would misclassify a valid PASS as FAIL (a false-block, and worse, it breaks
8974
+ # the unanimous-PASS Devil's-Advocate trigger -> indirect false-PASS). Take
8975
+ # the leading alphabetic run as the verdict word: "FAIL - [Critical] x" ->
8976
+ # FAIL, "PASS, no failures" -> PASS. Strip leading markdown emphasis first.
8977
+ verdict=$(printf '%s' "$verdict" | sed -E 's/^[*_`[:space:]]+//')
8978
+ local _vtok
8979
+ _vtok=$(printf '%s' "$verdict" | sed -E 's/[^A-Z].*$//')
8980
+ if [ -z "$_vtok" ]; then echo "NONE"; return 0; fi
8981
+ case "$_vtok" in
8982
+ FAIL|FAILED|FAILURE|REJECT|REJECTED|BLOCK|BLOCKED) echo "FAIL" ;;
8983
+ PASS|PASSED|APPROVE|APPROVED|OK) echo "PASS" ;;
8984
+ *) echo "AMBIGUOUS" ;;
8985
+ esac
8986
+ }
8987
+
8988
+ # WAVE8 FIX run.sh-F2 (HIGH): SAFE-DEFAULT severity detection. Returns 0
8989
+ # (blocking) if the reviewer file names a Critical or High severity finding in
8990
+ # any realistic emitted form: bracketed '[Critical]', bold '**Critical**',
8991
+ # 'Severity: High', or a bullet line '- Critical' / '* High'. The strict
8992
+ # bracket-only match previously missed unbracketed forms, so a FAIL naming an
8993
+ # unbracketed Critical was treated as non-blocking. BSD/GNU portable (no \b).
8994
+ _severity_is_blocking() {
8995
+ local file="$1"
8996
+ [ -f "$file" ] || return 1
8997
+ grep -qiE '(\[(critical|high)\])|(\*\*[[:space:]]*(critical|high)[[:space:]]*\*\*)|(severity:?[[:space:]]*(critical|high))|(^[[:space:]]*[-*][[:space:]]+(critical|high)([[:space:]:.,*]|$))' "$file"
8998
+ }
8999
+
8930
9000
  run_code_review() {
8931
9001
  local loki_dir="${TARGET_DIR:-.}/.loki"
8932
9002
  local review_dir="$loki_dir/quality/reviews"
@@ -9284,21 +9354,27 @@ BUILD_PROMPT
9284
9354
  continue
9285
9355
  fi
9286
9356
 
9287
- # Extract verdict
9357
+ # Extract + classify verdict (WAVE8 FIX run.sh-F1/F3). _classify_verdict
9358
+ # uses a markdown-tolerant anchor (rescues '**VERDICT:** FAIL') and a
9359
+ # SAFE-DEFAULT contract: FAIL=any FAIL/REJECT/BLOCK token (so verbose
9360
+ # "FAIL - [Critical] SQLi" / "FAIL." / "FAIL (3 criticals)" all count as
9361
+ # FAIL, previously mis-counted as PASS); PASS=PASS/APPROVE; AMBIGUOUS=a
9362
+ # verdict line that parses to neither; NONE=no parseable verdict line.
9288
9363
  local verdict
9289
- verdict=$(grep -i "^VERDICT:" "$review_output" | head -1 | sed 's/^VERDICT:[[:space:]]*//' | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')
9290
-
9291
- # FIX A2: a "real verdict" is the PRESENCE of a non-empty VERDICT: line,
9292
- # not a specific token. A non-empty file with NO VERDICT line (garbage or
9293
- # a truncated reply) previously counted as PASS and could approve the gate
9294
- # on a meaningless file; now it is a non-verdict (not real, not a pass).
9295
- # We deliberately keep the original non-FAIL=pass semantics for any file
9296
- # that DOES carry a verdict line (PASS, APPROVE, "PASS with concerns",
9297
- # etc. all count as pass) so verbose-but-real verdicts are never
9298
- # false-blocked. The only added block relative to shipped behavior is the
9299
- # zero-real-verdicts (all-empty) case.
9300
- if [ -z "$verdict" ]; then
9301
- log_warn "Reviewer $reviewer_name produced no VERDICT line (empty or unparseable reply)"
9364
+ verdict=$(_classify_verdict "$review_output")
9365
+
9366
+ # FIX A2 + WAVE8 FIX run.sh-F1/F3: a "real verdict" is a parseable
9367
+ # VERDICT line that classifies cleanly to PASS or FAIL. NONE (no usable
9368
+ # verdict line) AND AMBIGUOUS (a verdict line whose token is neither PASS
9369
+ # nor FAIL, e.g. "VERDICT: UNCLEAR") are BOTH routed to the NO_VERDICT
9370
+ # path. This is the SAFE-DEFAULT contract: an unparseable token must NOT
9371
+ # silently pass. It cannot count toward pass_count, and merely bumping
9372
+ # fail_count would be inert (only has_blocking / review_inconclusive gate
9373
+ # the return). So we treat it as a non-real verdict; the
9374
+ # real_verdict_count < reviewer_count check below then makes the review
9375
+ # inconclusive -> bounded retry -> block (FIX 3 machinery).
9376
+ if [ "$verdict" = "NONE" ] || [ "$verdict" = "AMBIGUOUS" ]; then
9377
+ log_warn "Reviewer $reviewer_name returned no usable verdict (empty, unparseable, or ambiguous token)"
9302
9378
  verdicts_summary="${verdicts_summary}${reviewer_name}:NO_VERDICT "
9303
9379
  ((no_output_count++))
9304
9380
  continue
@@ -9306,8 +9382,9 @@ BUILD_PROMPT
9306
9382
  ((real_verdict_count++))
9307
9383
  if [ "$verdict" = "FAIL" ]; then
9308
9384
  ((fail_count++))
9309
- # Check for Critical/High severity findings
9310
- if grep -qiE "\[(Critical|High)\]" "$review_output"; then
9385
+ # Check for Critical/High severity findings (bracketed OR unbracketed
9386
+ # OR bold OR 'Severity:' OR bullet form -- WAVE8 FIX run.sh-F2).
9387
+ if _severity_is_blocking "$review_output"; then
9311
9388
  has_blocking=true
9312
9389
  log_error "BLOCKING: $reviewer_name found Critical/High severity issues"
9313
9390
  else
@@ -9320,16 +9397,25 @@ BUILD_PROMPT
9320
9397
  verdicts_summary="${verdicts_summary}${reviewer_name}:${verdict:-UNKNOWN} "
9321
9398
  done
9322
9399
 
9323
- # Finding #596 FIX A2: zero real verdicts when reviewers were expected =>
9324
- # INCONCLUSIVE => blocking. Optional bounded retry first (LOKI_REVIEW_RETRY=1,
9325
- # default on) so a transient empty-output blip does not hard-block; the retry
9326
- # re-runs the whole review with the (now .loki-excluded) diff. Opt out of the
9327
- # block entirely with LOKI_REVIEW_INCONCLUSIVE_BLOCK=0 (records, never blocks).
9400
+ # Finding #596 FIX A2 + WAVE8 FIX run.sh-F3: a review is INCONCLUSIVE (=>
9401
+ # blocking) whenever FEWER reviewers returned a usable verdict than were
9402
+ # dispatched. The original gate only fired on real_verdict_count==0 (ALL
9403
+ # reviewers empty); a MIXED review (e.g. 1 of 3 NO_VERDICT, 2 PASS) silently
9404
+ # passed on the surviving majority and dropped the malformed reviewer's
9405
+ # potential dissent (Devil's Advocate never fired). Now ANY NO_VERDICT
9406
+ # reviewer makes the review inconclusive: a dropped reviewer is a dropped
9407
+ # vote, and the safe direction is to refuse to pass on a partial council.
9408
+ # The markdown-tolerant anchor in _classify_verdict already rescues most
9409
+ # real-but-wrapped verdicts, so this fires only on genuinely unusable output.
9410
+ # Optional bounded retry first (LOKI_REVIEW_RETRY=1, default on) so a
9411
+ # transient empty-output blip does not hard-block; the retry re-runs the
9412
+ # whole review with the (now .loki-excluded) diff. Opt out of the block
9413
+ # entirely with LOKI_REVIEW_INCONCLUSIVE_BLOCK=0 (records, never blocks).
9328
9414
  local review_inconclusive=false
9329
- if [ "$reviewer_count" -gt 0 ] && [ "$real_verdict_count" -eq 0 ]; then
9415
+ if [ "$reviewer_count" -gt 0 ] && [ "$real_verdict_count" -lt "$reviewer_count" ]; then
9330
9416
  review_inconclusive=true
9331
- log_error "CODE REVIEW INCONCLUSIVE: 0 of $reviewer_count reviewers returned a usable verdict (no_output=$no_output_count)"
9332
- log_error " An all-empty review proves nothing; refusing to pass the gate on zero real verdicts."
9417
+ log_error "CODE REVIEW INCONCLUSIVE: only $real_verdict_count of $reviewer_count reviewers returned a usable verdict (no_output=$no_output_count)"
9418
+ log_error " A partial review drops dissent; refusing to pass the gate without every reviewer's verdict."
9333
9419
  if [ "${LOKI_REVIEW_RETRY:-1}" = "1" ] && [ "${_LOKI_REVIEW_RETRYING:-0}" != "1" ]; then
9334
9420
  log_warn " Retrying code review once (LOKI_REVIEW_RETRY=1)..."
9335
9421
  _LOKI_REVIEW_RETRYING=1 run_code_review
@@ -9437,9 +9523,12 @@ BUILD_DA_PROMPT
9437
9523
  _dispatch_reviewer "$da_prompt_text" "$da_output" || true
9438
9524
 
9439
9525
  if [ -f "$da_output" ] && [ -s "$da_output" ]; then
9526
+ # WAVE8 FIX run.sh-F1/F2: classify with the shared SAFE-DEFAULT
9527
+ # helpers so a verbose DA "VERDICT: FAIL - [Critical] ..." (and
9528
+ # AMBIGUOUS tokens) and an unbracketed Critical/High both block.
9440
9529
  local da_verdict
9441
- da_verdict=$(grep -i "^VERDICT:" "$da_output" | head -1 | sed 's/^VERDICT:[[:space:]]*//' | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')
9442
- if [ "$da_verdict" = "FAIL" ] && grep -qiE "\[(Critical|High)\]" "$da_output"; then
9530
+ da_verdict=$(_classify_verdict "$da_output")
9531
+ if { [ "$da_verdict" = "FAIL" ] || [ "$da_verdict" = "AMBIGUOUS" ]; } && _severity_is_blocking "$da_output"; then
9443
9532
  has_blocking=true
9444
9533
  # Audit accuracy: aggregate.json was written above (line ~8429)
9445
9534
  # with has_blocking=false (entering this block requires a
@@ -9465,7 +9554,7 @@ DA_AGG_PATCH
9465
9554
  log_error "DEVIL'S ADVOCATE: found Critical/High issue the unanimous council missed -- BLOCK"
9466
9555
  {
9467
9556
  echo "DEVILS_ADVOCATE_BLOCK: Critical/High found after unanimous PASS"
9468
- grep -iE "\[(Critical|High)\]" "$da_output" || true
9557
+ grep -iE '(\[(critical|high)\])|(\*\*[[:space:]]*(critical|high)[[:space:]]*\*\*)|(severity:?[[:space:]]*(critical|high))|(^[[:space:]]*[-*][[:space:]]+(critical|high)([[:space:]:.,*]|$))' "$da_output" || true
9469
9558
  } >> "$review_dir/$review_id/anti-sycophancy.txt"
9470
9559
  else
9471
9560
  log_info "Devil's Advocate: no additional Critical/High issues found"
@@ -9487,16 +9576,16 @@ DA_AGG_PATCH
9487
9576
  return 1
9488
9577
  fi
9489
9578
 
9490
- # Finding #596 FIX A2: an inconclusive review (zero real verdicts, retry
9491
- # already exhausted or disabled) blocks unless explicitly opted out. This is
9492
- # the 'verified before done' promise: a review that produced no usable verdict
9493
- # cannot stand in for a real review.
9579
+ # Finding #596 FIX A2 + WAVE8 FIX run.sh-F3: an inconclusive review (fewer
9580
+ # usable verdicts than reviewers, retry already exhausted or disabled) blocks
9581
+ # unless explicitly opted out. This is the 'verified before done' promise: a
9582
+ # review missing any reviewer's verdict cannot stand in for a full review.
9494
9583
  if [ "$review_inconclusive" = "true" ]; then
9495
9584
  if [ "${LOKI_REVIEW_INCONCLUSIVE_BLOCK:-1}" = "0" ]; then
9496
- log_warn "Code review inconclusive (0/$reviewer_count real verdicts) but LOKI_REVIEW_INCONCLUSIVE_BLOCK=0 - not blocking"
9585
+ log_warn "Code review inconclusive ($real_verdict_count/$reviewer_count real verdicts) but LOKI_REVIEW_INCONCLUSIVE_BLOCK=0 - not blocking"
9497
9586
  return 0
9498
9587
  fi
9499
- log_error "CODE REVIEW BLOCKED: inconclusive (0/$reviewer_count reviewers returned a usable verdict)"
9588
+ log_error "CODE REVIEW BLOCKED: inconclusive ($real_verdict_count/$reviewer_count reviewers returned a usable verdict)"
9500
9589
  log_error " Review details: $review_dir/$review_id/ ; opt out with LOKI_REVIEW_INCONCLUSIVE_BLOCK=0"
9501
9590
  return 1
9502
9591
  fi
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.66.1"
10
+ __version__ = "7.67.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: