loki-mode 7.91.0 → 7.92.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
@@ -218,6 +218,11 @@ if [[ -z "${LOKI_RUNNING_FROM_TEMP:-}" ]] && [[ "${BASH_SOURCE[0]}" == "${0}" ]]
218
218
  # BUG-XC-011: Set trap BEFORE exec so the temp file gets cleaned up
219
219
  trap 'rm -f "$TEMP_SCRIPT"' EXIT
220
220
  export LOKI_RUNNING_FROM_TEMP=1
221
+ # Record the EXACT temp-copy path so the post-exec cleanup trap deletes THIS
222
+ # temp file and NEVER the canonical source (root cause of the recurring
223
+ # "run.sh self-deleted on build spawn" bug: an inherited LOKI_RUNNING_FROM_TEMP
224
+ # skips the self-copy block, leaving BASH_SOURCE[0]=the real run.sh).
225
+ export LOKI_TEMP_SCRIPT_PATH="$TEMP_SCRIPT"
221
226
  export LOKI_ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR"
222
227
  export LOKI_ORIGINAL_PROJECT_DIR="$PROJECT_DIR"
223
228
  exec "$TEMP_SCRIPT" "$@"
@@ -227,9 +232,14 @@ fi
227
232
  SCRIPT_DIR="${LOKI_ORIGINAL_SCRIPT_DIR:-$SCRIPT_DIR}"
228
233
  PROJECT_DIR="${LOKI_ORIGINAL_PROJECT_DIR:-$PROJECT_DIR}"
229
234
 
230
- # Clean up temp script on exit (only when running from temp copy)
231
- if [[ "${LOKI_RUNNING_FROM_TEMP:-}" == "1" ]]; then
232
- trap 'rm -f "${BASH_SOURCE[0]}" 2>/dev/null' EXIT
235
+ # Clean up ONLY the recorded temp copy, and ONLY if it is a real temp file.
236
+ # Deleting BASH_SOURCE[0] here was the bug: an inherited LOKI_RUNNING_FROM_TEMP
237
+ # made BASH_SOURCE[0] the canonical source, so run.sh deleted itself on spawned
238
+ # builds. Guard on the recorded path being a real /tmp/loki-run-* file.
239
+ if [[ "${LOKI_RUNNING_FROM_TEMP:-}" == "1" ]] \
240
+ && [[ -n "${LOKI_TEMP_SCRIPT_PATH:-}" ]] \
241
+ && [[ "${LOKI_TEMP_SCRIPT_PATH}" == /tmp/loki-run-* || "${LOKI_TEMP_SCRIPT_PATH}" == "${TMPDIR:-/tmp}"loki-run-* ]]; then
242
+ trap 'rm -f "${LOKI_TEMP_SCRIPT_PATH}" 2>/dev/null' EXIT
233
243
  fi
234
244
 
235
245
  #===============================================================================
@@ -1040,6 +1050,179 @@ log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
1040
1050
  log_step() { echo -e "${CYAN}[STEP]${NC} $*"; }
1041
1051
  log_debug() { [[ "${LOKI_DEBUG:-}" == "true" ]] && echo -e "${CYAN}[DEBUG]${NC} $*" >&2 || true; }
1042
1052
 
1053
+ #===============================================================================
1054
+ # Failure diagnosis helpers (T2.4 / T2.5 / T2.6)
1055
+ #
1056
+ # These make a failing or crashed build self-explanatory: a copy-pasteable
1057
+ # "loki why" hint on any non-zero exit, a durable CLASSIFIED LAST_ERROR record
1058
+ # on a failed iteration, and a best-effort terminal record on an untrapped
1059
+ # death. Every one is best-effort and NEVER alters the build's exit code.
1060
+ #===============================================================================
1061
+
1062
+ # _loki_surface_why_hint (T2.4): print the "loki why" hint to stderr and write
1063
+ # it to .loki/NEXT_STEPS.txt. Called once from main() finalization when the run
1064
+ # failed (result != 0). Best-effort: never crashes, never changes exit code.
1065
+ _loki_surface_why_hint() {
1066
+ local loki_dir="${TARGET_DIR:-.}/.loki"
1067
+ local hint="For a plain-language diagnosis of what happened, run: loki why"
1068
+ printf '%s\n' "$hint" >&2 || true
1069
+ mkdir -p "$loki_dir" 2>/dev/null || true
1070
+ printf '%s\n' "$hint" > "$loki_dir/NEXT_STEPS.txt" 2>/dev/null || true
1071
+ return 0
1072
+ }
1073
+
1074
+ # _loki_write_last_error (T2.5): write a durable, classified failure record to
1075
+ # .loki/state/LAST_ERROR.json. Schema:
1076
+ # {
1077
+ # "iteration": <int>,
1078
+ # "error_class": "provider_empty_output"|"build_timeout"|"rate_limited"
1079
+ # |"auth_error"|"unknown",
1080
+ # "brief": "<one honest sentence>",
1081
+ # "timestamp": "<UTC ISO-8601>"
1082
+ # }
1083
+ # This is what `loki why` (in autonomy/loki, NOT owned here) can later read.
1084
+ # Built via python3 so the free-text brief can never break the JSON. Entirely
1085
+ # best-effort: any failure is swallowed and the build is never crashed.
1086
+ # Usage: _loki_write_last_error <iteration> <error_class> <brief>
1087
+ _loki_write_last_error() {
1088
+ local iteration="${1:-0}"
1089
+ local error_class="${2:-unknown}"
1090
+ local brief="${3:-An iteration failed.}"
1091
+ local loki_dir="${TARGET_DIR:-.}/.loki"
1092
+ local state_dir="$loki_dir/state"
1093
+ mkdir -p "$state_dir" 2>/dev/null || true
1094
+ LOKI_LE_ITER="$iteration" \
1095
+ LOKI_LE_CLASS="$error_class" \
1096
+ LOKI_LE_BRIEF="$brief" \
1097
+ LOKI_LE_FILE="$state_dir/LAST_ERROR.json" \
1098
+ python3 -c "
1099
+ import json, os, tempfile
1100
+ try:
1101
+ rec = {
1102
+ 'iteration': int(os.environ.get('LOKI_LE_ITER', '0') or 0),
1103
+ 'error_class': os.environ.get('LOKI_LE_CLASS', 'unknown'),
1104
+ 'brief': os.environ.get('LOKI_LE_BRIEF', ''),
1105
+ 'timestamp': __import__('datetime').datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
1106
+ }
1107
+ target = os.environ['LOKI_LE_FILE']
1108
+ d = os.path.dirname(target)
1109
+ fd, tmp = tempfile.mkstemp(dir=d, suffix='.json')
1110
+ with os.fdopen(fd, 'w') as f:
1111
+ json.dump(rec, f)
1112
+ os.replace(tmp, target)
1113
+ except Exception:
1114
+ pass
1115
+ " 2>/dev/null || true
1116
+ return 0
1117
+ }
1118
+
1119
+ # _loki_classify_iteration_error (T2.5 helper): map an iteration's signals to one
1120
+ # of the LAST_ERROR error_class values. Conservative: only returns a specific
1121
+ # class when a signal confidently supports it, else "unknown". Never fabricates
1122
+ # build_timeout (no timeout signal is detected here, so it is reserved for a
1123
+ # caller that has one). Echoes the class on stdout.
1124
+ # Usage: _loki_classify_iteration_error <iter_output_file> <empty_output_flag 0|1>
1125
+ _loki_classify_iteration_error() {
1126
+ local iter_output="${1:-}"
1127
+ local empty_flag="${2:-0}"
1128
+ if [ "$empty_flag" = "1" ]; then
1129
+ echo "provider_empty_output"
1130
+ return 0
1131
+ fi
1132
+ # Rate limit: reuse the same detector the retry path uses.
1133
+ if [ -n "$iter_output" ] && [ -f "$iter_output" ] && detect_rate_limit "$iter_output" 2>/dev/null | grep -qE '^[1-9]'; then
1134
+ echo "rate_limited"
1135
+ return 0
1136
+ fi
1137
+ # Auth error: a clear 401/403/unauthorized in the output tail.
1138
+ if [ -n "$iter_output" ] && [ -f "$iter_output" ]; then
1139
+ local _tail
1140
+ _tail="$(tail -n 40 "$iter_output" 2>/dev/null || true)"
1141
+ if printf '%s\n' "$_tail" | grep -qiE '(http[ /]?40[13]|status[: ]+40[13]|unauthorized|invalid api key|authentication[_ ]error|401 )' 2>/dev/null; then
1142
+ echo "auth_error"
1143
+ return 0
1144
+ fi
1145
+ fi
1146
+ echo "unknown"
1147
+ return 0
1148
+ }
1149
+
1150
+ # _loki_terminal_record (T2.6, SAFE SUBSET): on an untrapped exit where the
1151
+ # persisted run status is still "running" (a true mid-provider-call crash on a
1152
+ # trappable signal -- SIGTERM/SIGINT/SIGHUP via the lock-release trap), leave a
1153
+ # best-effort, classified LAST_ERROR record so a post-crash `loki why` is not
1154
+ # stale. Piggybacks the existing lock-release EXIT trap (see main()) -- it does
1155
+ # NOT install a new broad EXIT trap.
1156
+ #
1157
+ # IMPORTANT (why this does NOT rewrite autonomy-state.json): the resume-detection
1158
+ # block in this file (search "Durable resume") keys the ENT-2 pod-loss resume on
1159
+ # prev_status == "running". Flipping the status to "exited" here would make a
1160
+ # crashed-but-resumable build (LOKI_DURABLE_STATE=1) reset to iteration 0 on the
1161
+ # next start, destroying durable progress. So this writes ONLY the LAST_ERROR
1162
+ # side-record (which is what `loki why` reads) and deliberately leaves the
1163
+ # status untouched. SIGKILL / power-loss are uncatchable (no trap fires); the
1164
+ # ENT-2 durable-resume path covers those. Never alters the exit code; best-effort.
1165
+ _loki_terminal_record() {
1166
+ local state_file
1167
+ state_file="$(_loki_state_file 2>/dev/null)" || return 0
1168
+ [ -n "$state_file" ] || return 0
1169
+ [ -f "$state_file" ] || return 0
1170
+ local _status
1171
+ _status="$(LOKI_TR_FILE="$state_file" python3 -c "
1172
+ import json, os
1173
+ try:
1174
+ print(json.load(open(os.environ['LOKI_TR_FILE'])).get('status','unknown'))
1175
+ except Exception:
1176
+ print('unknown')
1177
+ " 2>/dev/null || echo "unknown")"
1178
+ # Only act on a genuinely mid-flight "running" status. Any settled status
1179
+ # (council_approved, failed, exited, paused, ...) is left untouched.
1180
+ [ "$_status" = "running" ] || return 0
1181
+ # Leave a classified LAST_ERROR so `loki why` has something honest -- WITHOUT
1182
+ # touching autonomy-state.json (preserving the ENT-2 "running" resume signal).
1183
+ _loki_write_last_error "${ITERATION_COUNT:-0}" "unknown" \
1184
+ "The build process exited unexpectedly before finishing (possible crash or kill)." 2>/dev/null || true
1185
+ return 0
1186
+ }
1187
+
1188
+ # _loki_write_rate_limit_signal (T2.7): write a best-effort
1189
+ # .loki/signals/RATE_LIMITED file. This is FORWARD-LAID infrastructure: no
1190
+ # consumer reads it yet (a future dashboard / external watcher could, to tell a
1191
+ # normal provider rate-limit wait apart from a hang). The user-visible signal
1192
+ # today is the log_info line at the wait site; this file is the durable record. Schema:
1193
+ # {"rate_limited": true, "wait_seconds": <int>, "reset_time": "<string>"}
1194
+ # Built via python3 so the reset-time string can never break the JSON. Entirely
1195
+ # best-effort: never crashes, never alters the build.
1196
+ # Usage: _loki_write_rate_limit_signal <wait_seconds> <reset_time>
1197
+ _loki_write_rate_limit_signal() {
1198
+ local wait_seconds="${1:-0}"
1199
+ local reset_time="${2:-}"
1200
+ local signals_dir="${TARGET_DIR:-.}/.loki/signals"
1201
+ mkdir -p "$signals_dir" 2>/dev/null || true
1202
+ LOKI_RL_WAIT="$wait_seconds" \
1203
+ LOKI_RL_RESET="$reset_time" \
1204
+ LOKI_RL_FILE="$signals_dir/RATE_LIMITED" \
1205
+ python3 -c "
1206
+ import json, os, tempfile
1207
+ try:
1208
+ w = os.environ.get('LOKI_RL_WAIT', '0')
1209
+ try:
1210
+ w = int(w)
1211
+ except Exception:
1212
+ w = 0
1213
+ rec = {'rate_limited': True, 'wait_seconds': w, 'reset_time': os.environ.get('LOKI_RL_RESET', '')}
1214
+ target = os.environ['LOKI_RL_FILE']
1215
+ d = os.path.dirname(target)
1216
+ fd, tmp = tempfile.mkstemp(dir=d, suffix='.json')
1217
+ with os.fdopen(fd, 'w') as f:
1218
+ json.dump(rec, f)
1219
+ os.replace(tmp, target)
1220
+ except Exception:
1221
+ pass
1222
+ " 2>/dev/null || true
1223
+ return 0
1224
+ }
1225
+
1043
1226
  # Live Build HUD (v7.71.0): a single append-only per-iteration status line on the
1044
1227
  # interactive TTY path. Pure additive stdout decoration -- never piped into any
1045
1228
  # tee, so the dashboard agent.log and the stream-json parser are untouched. The
@@ -1384,6 +1567,34 @@ emit_event_json() {
1384
1567
  log_debug "Event: $event_type - $json_data"
1385
1568
  }
1386
1569
 
1570
+ # Per-stage timeline event (v7.91.x). Emits one stage_complete record per
1571
+ # quality-gate / build stage so the SaaS timeline can show where build time
1572
+ # goes within an iteration (the provider call itself is already bracketed by
1573
+ # iteration_start/iteration_complete). This is purely ADDITIVE: it appends one
1574
+ # event line via emit_event_json and never changes a gate verdict, gate exit
1575
+ # code, or control flow. Duration is computed by the caller (whole-second
1576
+ # resolution, sufficient for multi-second gates) and carried on the event so
1577
+ # the SaaS does not have to infer it from coarse ISO timestamps.
1578
+ # emit_stage_complete <stage_name> <status: pass|fail> <start_epoch_seconds>
1579
+ # Event shape:
1580
+ # {type:"stage_complete", timestamp, data:{stage, status, duration_s, iteration}}
1581
+ # Best-effort: any failure is swallowed so it can never block the build.
1582
+ emit_stage_complete() {
1583
+ local stage="$1"
1584
+ local status="$2"
1585
+ local t0="$3"
1586
+ local now dur
1587
+ now=$(date +%s 2>/dev/null) || return 0
1588
+ [ -n "$t0" ] || return 0
1589
+ dur=$(( now - t0 ))
1590
+ [ "$dur" -ge 0 ] 2>/dev/null || dur=0
1591
+ emit_event_json "stage_complete" \
1592
+ "stage=$stage" \
1593
+ "status=$status" \
1594
+ "duration_s=$dur" \
1595
+ "iteration=${ITERATION_COUNT:-0}" 2>/dev/null || true
1596
+ }
1597
+
1387
1598
  # Trust-layer metrics event writer (benchmark program section 3). Appends one
1388
1599
  # durable record per trust event to .loki/metrics/trust-events.jsonl via the
1389
1600
  # Python writer (single source of truth for the JSONL schema). This is ADDITIVE
@@ -1760,9 +1971,93 @@ get_iteration_duration_ms() {
1760
1971
  # Supports Docker/K8s secret file mounts as fallback.
1761
1972
  #===============================================================================
1762
1973
 
1974
+ # Zero-friction preflight helpers (T1.1). These run for ALL environments
1975
+ # (not just Docker/K8s) BEFORE the build starts, via validate_api_keys. git is a
1976
+ # genuine hard requirement (the build inits a repo) so a missing git BLOCKS with a
1977
+ # copy-pasteable fix; node-version and network reachability are ADVISORY (warn and
1978
+ # continue, fail-open) so a probabilistic or optional signal never blocks a working
1979
+ # user. The goal: surface real problems early without ever wrongly refusing to start.
1980
+ #
1981
+ # _loki_check_node_version: ADVISORY only. If node is present and its major
1982
+ # version is < 18, log a warning (node only matters for node-based builds) and
1983
+ # continue. If node is absent entirely this is a NO-OP. Always returns 0 -- it
1984
+ # never blocks the build (fail-open); the real node call, if any, is the test.
1985
+ _loki_check_node_version() {
1986
+ command -v node >/dev/null 2>&1 || return 0
1987
+ local node_version major
1988
+ node_version="$(node --version 2>/dev/null || echo '')"
1989
+ # node --version -> "v20.11.0"; extract the leading major integer.
1990
+ major="$(printf '%s' "$node_version" | sed -E 's/^v?([0-9]+).*/\1/')"
1991
+ # Advisory only: node is OPTIONAL (absence is a no-op above, and many builds -
1992
+ # Python/Go/Rust - never touch node). A present-but-old node only matters for
1993
+ # node-based builds, so WARN and continue (fail-open); never hard-block a
1994
+ # working user. The actual node call (only for JS/TS work) is the real test.
1995
+ if [ -n "$major" ] && [[ "$major" =~ ^[0-9]+$ ]] && [ "$major" -lt 18 ]; then
1996
+ log_warn "Node.js >= 18 recommended for node-based builds; found ${node_version:-unknown}. Upgrade if your project uses node: https://nodejs.org"
1997
+ fi
1998
+ return 0
1999
+ }
2000
+
2001
+ # _loki_check_git_present: the build initializes a git repo, so git is required.
2002
+ _loki_check_git_present() {
2003
+ if ! command -v git >/dev/null 2>&1; then
2004
+ log_error "Git is required (the build initializes a repo). Install: https://git-scm.com/downloads"
2005
+ return 1
2006
+ fi
2007
+ return 0
2008
+ }
2009
+
2010
+ # _loki_check_network_reachable: ADVISORY only. A fast (3s) reachability probe to
2011
+ # the active provider endpoint that WARNS and continues if it cannot reach it -- a
2012
+ # curl failure does not prove the provider CLI cannot connect (3s timeout under
2013
+ # load, transient DNS, or a proxy set for the CLI but not the shell all curl-fail
2014
+ # while the real build succeeds). Always returns 0 (fail-open); the actual
2015
+ # provider call is the authoritative connectivity test. Skipped entirely when curl
2016
+ # is missing, when LOKI_SKIP_NET_PREFLIGHT=1, when ANTHROPIC_BASE_URL is set (alt
2017
+ # provider endpoint we cannot assume), or for any provider whose endpoint we do
2018
+ # not know. It NEVER blocks the build.
2019
+ _loki_check_network_reachable() {
2020
+ local provider="${1:-claude}"
2021
+ [ "${LOKI_SKIP_NET_PREFLIGHT:-}" = "1" ] && return 0
2022
+ command -v curl >/dev/null 2>&1 || return 0
2023
+ # Alternate provider base URL set -> do not assume the default endpoint.
2024
+ [ -n "${ANTHROPIC_BASE_URL:-}" ] && return 0
2025
+
2026
+ local endpoint=""
2027
+ case "$provider" in
2028
+ claude) endpoint="https://api.anthropic.com" ;;
2029
+ *) return 0 ;; # unknown endpoint -> fail open, never guess
2030
+ esac
2031
+
2032
+ # Advisory only: a fast curl probe failing does NOT prove the provider CLI
2033
+ # cannot connect (a 3s timeout under load, transient DNS, or a proxy set for
2034
+ # the CLI but not the shell all curl-fail while the real build succeeds). WARN
2035
+ # and continue - the actual provider call is the authoritative connectivity
2036
+ # test. Silence this with LOKI_SKIP_NET_PREFLIGHT=1. Never hard-block here.
2037
+ if ! curl -sS -m 3 -o /dev/null "$endpoint" 2>/dev/null; then
2038
+ log_warn "Could not verify network reachability to the AI provider (firewall/VPN/transient?). Continuing; the provider call will be the real test. Silence with LOKI_SKIP_NET_PREFLIGHT=1."
2039
+ fi
2040
+ return 0
2041
+ }
2042
+
1763
2043
  validate_api_keys() {
1764
2044
  local provider="${LOKI_PROVIDER:-claude}"
1765
2045
 
2046
+ # Zero-friction preflight (T1.1): toolchain + reachability checks that apply
2047
+ # to EVERY environment, run BEFORE the Docker/K8s early-return below so they
2048
+ # are not silently skipped in the common local case. Node/git are genuinely
2049
+ # required (node only when present-but-too-old); the network probe is
2050
+ # fail-open and opt-out (LOKI_SKIP_NET_PREFLIGHT=1).
2051
+ if ! _loki_check_node_version; then
2052
+ return 1
2053
+ fi
2054
+ if ! _loki_check_git_present; then
2055
+ return 1
2056
+ fi
2057
+ if ! _loki_check_network_reachable "$provider"; then
2058
+ return 1
2059
+ fi
2060
+
1766
2061
  # CLI tools (claude, codex, cline, aider) use their own login sessions.
1767
2062
  # Only require API keys inside Docker/K8s where CLI login isn't available.
1768
2063
  if [[ ! -f "/.dockerenv" ]] && [[ -z "${KUBERNETES_SERVICE_HOST:-}" ]]; then
@@ -6189,6 +6484,152 @@ audit_log() {
6189
6484
  echo "$log_entry" >> "$audit_file"
6190
6485
  }
6191
6486
 
6487
+ #===============================================================================
6488
+ # Engine-owned workspace git-init (Plan #16, Option A-1)
6489
+ #===============================================================================
6490
+
6491
+ # Resolve a path to its physical absolute form (symlinks + .. collapsed) using
6492
+ # whatever is available; falls back to the input unchanged. Portable across the
6493
+ # BSD (macOS) and GNU userlands the engine runs on.
6494
+ _loki_resolve_path() {
6495
+ local p="${1:-}"
6496
+ [ -n "$p" ] || { printf '%s' ""; return 0; }
6497
+ if command -v realpath >/dev/null 2>&1; then
6498
+ realpath "$p" 2>/dev/null && return 0
6499
+ fi
6500
+ # python3 is a hard engine dependency; use it as the portable fallback.
6501
+ python3 - "$p" <<'PYRESOLVE' 2>/dev/null && return 0
6502
+ import os, sys
6503
+ print(os.path.realpath(sys.argv[1]))
6504
+ PYRESOLVE
6505
+ printf '%s' "$p"
6506
+ }
6507
+
6508
+ # True (0) when TARGET_DIR is an engine-owned, freshly-minted build workspace
6509
+ # that Loki may auto-git-init without surprising a user. Two honest signals:
6510
+ # 1. LOKI_AUTO_GIT_INIT=1 -- explicit opt-in (non-SaaS automation).
6511
+ # 2. LOKI_TARGET_DIR is set AND realpath-contained under one of the
6512
+ # colon-separated LOKI_WORKSPACE_ROOTS dirs (the v7.91.0 SaaS route: the
6513
+ # BFF mints <root>/<buildId> and the server pins LOKI_TARGET_DIR to it).
6514
+ # A user's own folder (`loki start ./prd.md`, no workspace, roots unset) is
6515
+ # NEVER engine-owned -- it must not get a silent .git. Realpath containment (not
6516
+ # a prefix string match) so /root/build-other does not match /root/build.
6517
+ _loki_workspace_is_engine_owned() {
6518
+ [ "${LOKI_AUTO_GIT_INIT:-0}" = "1" ] && return 0
6519
+
6520
+ local roots_raw="${LOKI_WORKSPACE_ROOTS:-}"
6521
+ [ -n "${LOKI_TARGET_DIR:-}" ] || return 1
6522
+ [ -n "$roots_raw" ] || return 1
6523
+
6524
+ local ws_real root_real
6525
+ ws_real="$(_loki_resolve_path "${TARGET_DIR:-.}")"
6526
+ [ -n "$ws_real" ] || return 1
6527
+
6528
+ local IFS=':'
6529
+ local root
6530
+ for root in $roots_raw; do
6531
+ [ -n "$root" ] || continue
6532
+ root_real="$(_loki_resolve_path "$root")"
6533
+ [ -n "$root_real" ] || continue
6534
+ # Exact match or contained: ws == root, or ws starts with root + "/".
6535
+ if [ "$ws_real" = "$root_real" ] || case "$ws_real" in "$root_real"/*) true ;; *) false ;; esac; then
6536
+ return 0
6537
+ fi
6538
+ done
6539
+ return 1
6540
+ }
6541
+
6542
+ # Plan #16 Option A-1: establish git in an engine-owned build workspace so the
6543
+ # review/verify gate (run_code_review) and branch-isolation chain can actually
6544
+ # run. Without git history, run_code_review's diff resolves empty and the gate
6545
+ # SKIPS (silently reporting PASS) -- so a build that was never reviewed could
6546
+ # earn a VERIFIED receipt. This makes the gate RUN; it does NOT force a green
6547
+ # (a build whose review/tests fail still gets an honest verdict).
6548
+ #
6549
+ # Constraints honored:
6550
+ # - Engine-owned workspaces ONLY (see _loki_workspace_is_engine_owned). A
6551
+ # user's own folder is never silently git-init'd.
6552
+ # - Already a git repo (user's repo OR the engine source tree) -> NO-OP. Only
6553
+ # a non-git workspace is initialized.
6554
+ # - One INITIAL commit (not a bare init): a zero-commit unborn HEAD breaks the
6555
+ # start-SHA capture (git rev-parse HEAD) and re-trips the HEAD~1 skip. The
6556
+ # commit uses --allow-empty because a greenfield workspace at build start
6557
+ # holds only .loki/ (the spec lands in .loki/specs/), which .loki/.gitignore
6558
+ # excludes -> nothing to stage -> a bare commit would fail and leave an
6559
+ # unborn HEAD. --allow-empty guarantees a real HEAD either way.
6560
+ # - Neutral repo-local identity (loki-build) so the initial commit AND the
6561
+ # later commit_session_changes commit never inherit a developer's global git
6562
+ # identity. Repo-local sticks on a freshly-init'd workspace (no revert hook).
6563
+ # - Secrets never committed: brownfield files are staged through the same
6564
+ # secret-scan guard (_commit_path_looks_secret / _commit_scan_secret_file)
6565
+ # used by commit_session_changes; any offender unstages the whole set and
6566
+ # the initial commit falls back to --allow-empty (HEAD still established).
6567
+ maybe_git_init_engine_workspace() {
6568
+ command -v git >/dev/null 2>&1 || return 0
6569
+ _loki_workspace_is_engine_owned || return 0
6570
+
6571
+ local ws="${TARGET_DIR:-.}"
6572
+ [ -d "$ws" ] || return 0
6573
+
6574
+ # Already a git repo (user repo or engine source tree): do nothing.
6575
+ if git -C "$ws" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
6576
+ return 0
6577
+ fi
6578
+
6579
+ log_info "Engine-owned workspace is not a git repo; initializing for review/verify gates"
6580
+
6581
+ if ! git -C "$ws" init -q >/dev/null 2>&1; then
6582
+ log_warn "git init failed in engine workspace; review gate will skip (non-fatal)"
6583
+ return 0
6584
+ fi
6585
+
6586
+ # Neutral repo-local identity for the initial AND session-end commits.
6587
+ git -C "$ws" config user.name "loki-build" >/dev/null 2>&1 || true
6588
+ git -C "$ws" config user.email "loki-build@autonomi.dev" >/dev/null 2>&1 || true
6589
+
6590
+ # Self-ignore .loki/ runtime state so no commit ever stages it (mirrors
6591
+ # setup_agent_branch). Idempotent.
6592
+ mkdir -p "$ws/.loki" 2>/dev/null || true
6593
+ [ -f "$ws/.loki/.gitignore" ] || printf '*\n' > "$ws/.loki/.gitignore" 2>/dev/null || true
6594
+
6595
+ # Stage everything except .loki/ and an obvious-secret-path denylist (same
6596
+ # first-cut excludes commit_session_changes uses). The scan loop below is the
6597
+ # real guarantee for nested/weak secrets.
6598
+ git -C "$ws" add -A \
6599
+ ':!.loki' ':!.loki/' \
6600
+ ':!.env' ':!.env.*' ':!*.env' \
6601
+ ':!*.key' ':!*.pem' ':!*.p12' ':!*.keystore' \
6602
+ ':!id_rsa*' ':!*.token' ':!credentials*' >/dev/null 2>&1 || true
6603
+
6604
+ # Secret-scan staged files. ANY offender -> unstage all (safe default: never
6605
+ # commit a possible secret). The --allow-empty commit below still runs so a
6606
+ # real HEAD is established regardless.
6607
+ local _offenders=""
6608
+ local _f
6609
+ while IFS= read -r -d '' _f; do
6610
+ [ -f "$ws/$_f" ] || continue
6611
+ if _commit_path_looks_secret "$ws/$_f" || _commit_scan_secret_file "$ws/$_f"; then
6612
+ _offenders="${_offenders}${_offenders:+, }${_f}"
6613
+ fi
6614
+ done < <(git -C "$ws" diff --cached --name-only -z 2>/dev/null)
6615
+
6616
+ if [ -n "$_offenders" ]; then
6617
+ git -C "$ws" reset >/dev/null 2>&1 || true
6618
+ log_warn "Initial commit: possible secret in ${_offenders}; left unstaged (baseline commit will be empty)"
6619
+ fi
6620
+
6621
+ # ONE initial commit. --allow-empty: a greenfield workspace stages nothing
6622
+ # (only .loki/, which is ignored), so a bare commit would fail and leave an
6623
+ # unborn HEAD -- the exact failure mode this whole block exists to avoid.
6624
+ if git -C "$ws" commit --allow-empty -q -m "loki: initial build workspace baseline" >/dev/null 2>&1; then
6625
+ log_info "Initialized git in engine workspace (initial baseline commit created)"
6626
+ audit_log "WORKSPACE_GIT_INIT" "workspace=$ws"
6627
+ else
6628
+ log_warn "Initial baseline commit failed; review gate may skip (non-fatal)"
6629
+ fi
6630
+ return 0
6631
+ }
6632
+
6192
6633
  #===============================================================================
6193
6634
  # Branch Protection for Agent Changes
6194
6635
  #===============================================================================
@@ -9673,17 +10114,89 @@ run_code_review() {
9673
10114
  # common case) because git ignores the exclude pathspec for paths it does not
9674
10115
  # track. ':(exclude).git/' is harmless (git never diffs .git/) and is kept
9675
10116
  # only for parity with the evidence-gate exclusion list.
9676
- local _review_pathspec=(-- . ':(exclude).loki/' ':(exclude).git/' ':(exclude)**/.loki/**')
9677
- local diff_content
9678
- diff_content=$(git -C "${TARGET_DIR:-.}" diff HEAD~1 "${_review_pathspec[@]}" 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --cached "${_review_pathspec[@]}" 2>/dev/null || echo "")
10117
+ # Finding #596 + Plan #16: exclude .loki/, .git/, AND the standard dependency
10118
+ # / build noise dirs. The A-2 temp-index `git add -A` below stages EVERY
10119
+ # untracked file, and a fresh greenfield workspace has no root .gitignore yet
10120
+ # (only .loki/.gitignore, scoped to .loki/). Without these excludes a build
10121
+ # that ran `npm install` / `pip install` before writing a .gitignore would
10122
+ # stage node_modules/ (or .venv/, dist/, build/) into the temp index, bloat
10123
+ # the review diff to multi-MB, overflow the reviewer prompt, force NO_OUTPUT,
10124
+ # and defeat the gate -- exactly the Finding #596 class. The diff pathspec
10125
+ # filters the OUTPUT regardless of what the temp index staged, so this one
10126
+ # list fixes both diff_content and changed_files. Mirrors the metrics-path
10127
+ # noise set (run.sh:12592-12598) plus __pycache__ and vendor.
10128
+ local _review_pathspec=(-- . \
10129
+ ':(exclude).loki/' ':(exclude).git/' ':(exclude)**/.loki/**' \
10130
+ ':(exclude)node_modules/' ':(exclude)**/node_modules/**' \
10131
+ ':(exclude)venv/' ':(exclude).venv/' ':(exclude)**/venv/**' ':(exclude)**/.venv/**' \
10132
+ ':(exclude)dist/' ':(exclude)build/' ':(exclude)**/dist/**' ':(exclude)**/build/**' \
10133
+ ':(exclude)__pycache__/' ':(exclude)**/__pycache__/**' \
10134
+ ':(exclude)vendor/' ':(exclude)**/vendor/**')
10135
+
10136
+ # Plan #16 (A-2): make the review diff base robust to shallow/fresh history,
10137
+ # and surface NEW (untracked) files -- the whole greenfield build is new
10138
+ # files, which `git diff <base>` (tracked-only) never shows.
10139
+ #
10140
+ # Base preference, mirroring the proven metrics-path fallback chain:
10141
+ # 1. ${_LOKI_RUN_START_SHA} when it resolves to a commit -- the correct
10142
+ # per-run baseline (captured at run start; also what the summary/"Review
10143
+ # the work" command uses). After A-1, a fresh workspace has exactly one
10144
+ # commit at iteration 1, so HEAD~1 does NOT resolve -- the start-SHA is
10145
+ # what makes the gate run on iteration 1.
10146
+ # 2. HEAD~1 when it resolves (the live engine-source case, deep history).
10147
+ # 3. the git empty-tree object (computed, never a hardcoded SHA-1 constant,
10148
+ # so it survives a SHA-256 repo) so a single-commit repo still yields a
10149
+ # real diff against an empty baseline instead of an empty string.
10150
+ local _review_base=""
10151
+ if [ -n "${_LOKI_RUN_START_SHA:-}" ] && \
10152
+ git -C "${TARGET_DIR:-.}" rev-parse --verify --quiet "${_LOKI_RUN_START_SHA}^{commit}" >/dev/null 2>&1; then
10153
+ _review_base="${_LOKI_RUN_START_SHA}"
10154
+ elif git -C "${TARGET_DIR:-.}" rev-parse --verify --quiet 'HEAD~1^{commit}' >/dev/null 2>&1; then
10155
+ _review_base="HEAD~1"
10156
+ else
10157
+ _review_base="$(git -C "${TARGET_DIR:-.}" hash-object -t tree /dev/null 2>/dev/null || echo "")"
10158
+ fi
10159
+
10160
+ # Build the review diff against a THROWAWAY index (GIT_INDEX_FILE points at a
10161
+ # fresh, nonexistent path) so a `git add -A` captures the full working tree --
10162
+ # new + modified files alike -- WITHOUT touching the real index. This avoids
10163
+ # disturbing any other porcelain consumer (the evidence hard gate's status
10164
+ # read, create_checkpoint's `git stash create`, commit_session_changes). The
10165
+ # `.loki/.gitignore` (`*`) keeps runtime state out; the pathspec is a belt-
10166
+ # and-suspenders exclude. `diff --cached <base>` then compares that staged
10167
+ # snapshot to the baseline -- a real unified diff the reviewer can read.
10168
+ local diff_content=""
10169
+ local changed_files=""
10170
+ if [ -n "$_review_base" ]; then
10171
+ local _rev_idx
10172
+ _rev_idx="$(mktemp -u "${TMPDIR:-/tmp}/loki-revidx.XXXXXX")"
10173
+ GIT_INDEX_FILE="$_rev_idx" git -C "${TARGET_DIR:-.}" add -A 2>/dev/null || true
10174
+ diff_content=$(GIT_INDEX_FILE="$_rev_idx" git -C "${TARGET_DIR:-.}" diff --cached "$_review_base" "${_review_pathspec[@]}" 2>/dev/null || echo "")
10175
+ changed_files=$(GIT_INDEX_FILE="$_rev_idx" git -C "${TARGET_DIR:-.}" diff --cached --name-only "$_review_base" "${_review_pathspec[@]}" 2>/dev/null || echo "")
10176
+ rm -f "$_rev_idx" 2>/dev/null || true
10177
+ fi
10178
+
9679
10179
  if [ -z "$diff_content" ]; then
9680
- log_info "Code review: No diff to review, skipping"
10180
+ # Honesty (Finding #596 class): a silent PASS on an empty diff is a trust
10181
+ # gap ONLY when the run actually produced changes we failed to diff.
10182
+ # Distinguish a genuine no-op iteration (nothing changed -> legitimate
10183
+ # PASS) from "changes exist but we could not compute a diff" (must not
10184
+ # pass silently). Use the same .loki/.git-excluding porcelain the
10185
+ # evidence gate uses to detect real changes independent of the diff base.
10186
+ local _dirty
10187
+ _dirty=$(git -C "${TARGET_DIR:-.}" status --porcelain "${_review_pathspec[@]}" 2>/dev/null | head -1 || echo "")
10188
+ if [ -n "$_dirty" ] || [ -n "$changed_files" ]; then
10189
+ # Return non-zero so the gate dispatcher records a failure (it calls
10190
+ # track_gate_failure on the else branch). Do NOT call track_gate_failure
10191
+ # here: it echoes its count to stdout (callers capture it) and the
10192
+ # dispatcher would double-count.
10193
+ log_warn "Code review: workspace has changes but the review diff is empty (could not compute a diff base); NOT passing the gate silently"
10194
+ return 1
10195
+ fi
10196
+ log_info "Code review: no changes this iteration, skipping (genuine no-op)"
9681
10197
  return 0
9682
10198
  fi
9683
10199
 
9684
- local changed_files
9685
- changed_files=$(git -C "${TARGET_DIR:-.}" diff --name-only HEAD~1 "${_review_pathspec[@]}" 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --name-only --cached "${_review_pathspec[@]}" 2>/dev/null || echo "")
9686
-
9687
10200
  log_header "CODE REVIEW: $review_id"
9688
10201
 
9689
10202
  # Phase 3 (v7.0.0): managed code-review council. When the flag is on,
@@ -15914,9 +16427,13 @@ if __name__ == "__main__":
15914
16427
  esac
15915
16428
 
15916
16429
  # BUG-EC-013: Detect empty provider output (0 bytes = no work done)
16430
+ # T2.5: track this distinct cause so the failure path can classify the
16431
+ # durable LAST_ERROR record as provider_empty_output specifically.
16432
+ local _empty_output=0
15917
16433
  if [ -f "$iter_output" ] && [ ! -s "$iter_output" ] && [ $exit_code -eq 0 ]; then
15918
16434
  log_warn "Provider returned empty output (0 bytes) despite exit code 0 -- treating as error"
15919
16435
  exit_code=1
16436
+ _empty_output=1
15920
16437
  fi
15921
16438
 
15922
16439
  save_state $retry "exited" $exit_code
@@ -16029,27 +16546,33 @@ if __name__ == "__main__":
16029
16546
  # Static analysis gate
16030
16547
  if [ "${PHASE_STATIC_ANALYSIS:-true}" = "true" ]; then
16031
16548
  log_info "Quality gate: static analysis..."
16549
+ local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
16032
16550
  if enforce_static_analysis; then
16033
16551
  clear_gate_failure "static_analysis"
16034
16552
  else
16553
+ _stg_ok=fail
16035
16554
  local sa_count
16036
16555
  sa_count=$(track_gate_failure "static_analysis")
16037
16556
  gate_failures="${gate_failures}static_analysis,"
16038
16557
  log_warn "Static analysis FAILED ($sa_count consecutive) - findings injected into next iteration"
16039
16558
  fi
16559
+ emit_stage_complete "static_analysis" "$_stg_ok" "$_stg_t0"
16040
16560
  fi
16041
16561
  # Secure-by-default scan (v7.87.0). Advisory by default (never
16042
16562
  # blocks); records .loki/quality/security-findings.json each
16043
16563
  # iteration. Blocks only on un-waived HIGH when LOKI_SECURE_GATE=block.
16044
16564
  log_info "Quality gate: security scan (advisory)..."
16565
+ local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
16045
16566
  if run_secure_scan; then
16046
16567
  clear_gate_failure "security_scan"
16047
16568
  else
16569
+ _stg_ok=fail
16048
16570
  local sec_count
16049
16571
  sec_count=$(track_gate_failure "security_scan")
16050
16572
  gate_failures="${gate_failures}security_scan,"
16051
16573
  log_warn "Security gate BLOCKED ($sec_count consecutive) - un-waived HIGH findings (LOKI_SECURE_GATE=block)"
16052
16574
  fi
16575
+ emit_stage_complete "security_scan" "$_stg_ok" "$_stg_t0"
16053
16576
  # BUG-ST-002: Check pause signal between quality gates
16054
16577
  if [ -f "${TARGET_DIR:-.}/.loki/PAUSE" ] || [ -f "${TARGET_DIR:-.}/.loki/STOP" ]; then
16055
16578
  log_warn "Pause/stop signal detected between quality gates - deferring remaining gates"
@@ -16063,11 +16586,13 @@ if __name__ == "__main__":
16063
16586
  # Test coverage gate
16064
16587
  if [ "${PHASE_UNIT_TESTS:-true}" = "true" ]; then
16065
16588
  log_info "Quality gate: test suite (pass/fail)..."
16589
+ local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
16066
16590
  # F49: isolate HOME so the project's suite cannot pollute the
16067
16591
  # user's real home when it execs the generated app.
16068
16592
  if _loki_with_app_sandbox enforce_test_coverage; then
16069
16593
  clear_gate_failure "test_coverage"
16070
16594
  else
16595
+ _stg_ok=fail
16071
16596
  local tc_count
16072
16597
  tc_count=$(track_gate_failure "test_coverage")
16073
16598
  gate_failures="${gate_failures}test_coverage,"
@@ -16081,6 +16606,7 @@ if __name__ == "__main__":
16081
16606
  log_warn "Test suite gate FAILED ($tc_count consecutive) - must pass next iteration"
16082
16607
  fi
16083
16608
  fi
16609
+ emit_stage_complete "test_suite" "$_stg_ok" "$_stg_t0"
16084
16610
  fi
16085
16611
  # BUG-ST-002: Check pause signal between quality gates (after test coverage)
16086
16612
  if [ -f "${TARGET_DIR:-.}/.loki/PAUSE" ] || [ -f "${TARGET_DIR:-.}/.loki/STOP" ]; then
@@ -16093,26 +16619,32 @@ if __name__ == "__main__":
16093
16619
  # Mock integrity gate (P0-3): block on CRITICAL/HIGH mock problems.
16094
16620
  if [ "${LOKI_GATE_MOCK:-true}" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
16095
16621
  log_info "Quality gate: mock integrity..."
16622
+ local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
16096
16623
  if enforce_mock_integrity; then
16097
16624
  clear_gate_failure "mock_integrity"
16098
16625
  else
16626
+ _stg_ok=fail
16099
16627
  local mk_count
16100
16628
  mk_count=$(track_gate_failure "mock_integrity")
16101
16629
  gate_failures="${gate_failures}mock_integrity,"
16102
16630
  log_warn "Mock integrity gate FAILED ($mk_count consecutive) - CRITICAL/HIGH mock problems"
16103
16631
  fi
16632
+ emit_stage_complete "mock_integrity" "$_stg_ok" "$_stg_t0"
16104
16633
  fi
16105
16634
  # Test mutation integrity gate (P0-3): block on HIGH test-fitting.
16106
16635
  if [ "${LOKI_GATE_MUTATION:-true}" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
16107
16636
  log_info "Quality gate: test mutation integrity..."
16637
+ local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
16108
16638
  if enforce_mutation_integrity; then
16109
16639
  clear_gate_failure "mutation_integrity"
16110
16640
  else
16641
+ _stg_ok=fail
16111
16642
  local mt_count
16112
16643
  mt_count=$(track_gate_failure "mutation_integrity")
16113
16644
  gate_failures="${gate_failures}mutation_integrity,"
16114
16645
  log_warn "Mutation integrity gate FAILED ($mt_count consecutive) - HIGH test-fitting detected"
16115
16646
  fi
16647
+ emit_stage_complete "mutation_integrity" "$_stg_ok" "$_stg_t0"
16116
16648
  fi
16117
16649
  # LSP diagnostics gate (P1-5 bash-route parity, v7.51.0; default-on
16118
16650
  # advisory-surfacing as of v7.57.0). Closes the parity gap: the Bun
@@ -16148,6 +16680,7 @@ if __name__ == "__main__":
16148
16680
  # runLSPDiagnosticsWriter: cwd=REPO_ROOT, --root=ctx.cwd).
16149
16681
  if { [ "${LOKI_GATE_LSP_DIAGNOSTICS:-true}" = "true" ] || [ "${LOKI_GATE_LSP_DIAGNOSTICS:-true}" = "1" ]; } && [ "$ITERATION_COUNT" -gt 0 ]; then
16150
16682
  log_info "Quality gate: LSP diagnostics..."
16683
+ local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
16151
16684
  # WRITER: route-neutral Python, same program as the Bun route.
16152
16685
  if [ "${LOKI_GATE_LSP_WRITER:-1}" != "0" ]; then
16153
16686
  ( cd "$PROJECT_DIR" && LOKI_DIR="${TARGET_DIR:-.}/.loki" python3 -m mcp.lsp_proxy --write-diagnostics --root "${TARGET_DIR:-.}" ) >/dev/null 2>&1 || true
@@ -16181,6 +16714,7 @@ else:
16181
16714
  fi
16182
16715
  case "$_lsp_verdict" in
16183
16716
  block*)
16717
+ _stg_ok=fail
16184
16718
  local _lsp_e _lsp_w
16185
16719
  _lsp_e=$(printf '%s' "$_lsp_verdict" | awk '{print $2}')
16186
16720
  _lsp_w=$(printf '%s' "$_lsp_verdict" | awk '{print $3}')
@@ -16207,6 +16741,7 @@ else:
16207
16741
  log_info "LSP diagnostics: no lsp-diagnostics.json artifact (lsp not available) -- gate did not run"
16208
16742
  ;;
16209
16743
  esac
16744
+ emit_stage_complete "lsp_diagnostics" "$_stg_ok" "$_stg_t0"
16210
16745
  fi
16211
16746
  # Semantic test-authenticity gate -- mid-iteration ADVISORY arm
16212
16747
  # (v7.57.0 default-on surfacing). Clones the mock arm (~15126)
@@ -16259,9 +16794,11 @@ else:
16259
16794
  # Code review gate (upgraded from advisory, with escalation)
16260
16795
  if [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
16261
16796
  log_info "Quality gate: code review..."
16797
+ local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
16262
16798
  if run_code_review; then
16263
16799
  clear_gate_failure "code_review"
16264
16800
  else
16801
+ _stg_ok=fail
16265
16802
  local cr_count
16266
16803
  cr_count=$(track_gate_failure "code_review")
16267
16804
  # BUG-QG-007: Always append to gate_failures regardless of escalation tier
@@ -16286,7 +16823,7 @@ else:
16286
16823
  esac
16287
16824
  fi
16288
16825
  if [ "$_phase1_overrode" = "true" ]; then
16289
- : # BLOCK lifted; continue without escalation
16826
+ _stg_ok=pass # BLOCK lifted; continue without escalation
16290
16827
  elif [ "$cr_count" -ge "$GATE_PAUSE_LIMIT" ]; then
16291
16828
  log_error "Gate escalation: code_review failed $cr_count times (>= $GATE_PAUSE_LIMIT) - forcing PAUSE for human intervention"
16292
16829
  echo "PAUSE" > "${TARGET_DIR:-.}/.loki/signals/GATE_ESCALATION"
@@ -16316,6 +16853,7 @@ else:
16316
16853
  bun "${SCRIPT_DIR}/../loki-ts/dist/loki.js" internal phase1-hooks reflect "$ITERATION_COUNT" 2>/dev/null || true
16317
16854
  fi
16318
16855
  fi
16856
+ emit_stage_complete "code_review" "$_stg_ok" "$_stg_t0"
16319
16857
  fi
16320
16858
  # Auto-generate docs (default-on) BEFORE the staleness check and the
16321
16859
  # gate, so neither nags the user to run 'loki docs generate' by hand.
@@ -16330,26 +16868,32 @@ else:
16330
16868
  # Documentation quality gate - Gate 7 (Documentation Coverage)
16331
16869
  if [ "${LOKI_GATE_DOC_COVERAGE:-true}" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
16332
16870
  log_info "Quality gate: documentation coverage..."
16871
+ local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
16333
16872
  if run_doc_quality_gate; then
16334
16873
  clear_gate_failure "doc_coverage"
16335
16874
  else
16875
+ _stg_ok=fail
16336
16876
  local dc_count
16337
16877
  dc_count=$(track_gate_failure "doc_coverage")
16338
16878
  gate_failures="${gate_failures}doc_coverage,"
16339
16879
  log_warn "Documentation coverage gate: Score below threshold ($dc_count consecutive)"
16340
16880
  fi
16881
+ emit_stage_complete "doc_coverage" "$_stg_ok" "$_stg_t0"
16341
16882
  fi
16342
16883
  # Magic Modules debate gate - Gate 12 (v6.77.0)
16343
16884
  if [ "${LOKI_GATE_MAGIC_DEBATE:-true}" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
16344
16885
  log_info "Quality gate: magic modules debate..."
16886
+ local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
16345
16887
  if run_magic_debate_gate; then
16346
16888
  clear_gate_failure "magic_debate"
16347
16889
  else
16890
+ _stg_ok=fail
16348
16891
  local md_count
16349
16892
  md_count=$(track_gate_failure "magic_debate")
16350
16893
  gate_failures="${gate_failures}magic_debate,"
16351
16894
  log_warn "Magic Modules debate gate: BLOCK severity detected ($md_count consecutive)"
16352
16895
  fi
16896
+ emit_stage_complete "magic_debate" "$_stg_ok" "$_stg_t0"
16353
16897
  fi
16354
16898
  # Store gate failures for prompt injection
16355
16899
  if [ -n "$gate_failures" ]; then
@@ -16684,6 +17228,24 @@ else:
16684
17228
  # the "Will retry" log_warn below. TTY-gated, `|| true`, never tee'd.
16685
17229
  render_build_hud "${ITERATION_COUNT:-0}" "${rarv_phase:-?}" "${duration:-0}" || true
16686
17230
 
17231
+ # T2.5: durable, classified failure record for `loki why`. Best-effort,
17232
+ # never crashes the build. Skip signal-induced exits (130 SIGINT /
17233
+ # 143 SIGTERM / 137 SIGKILL): a user/operator interrupt is not an error
17234
+ # to record (mirrors the crash-capture exclusion above). Classification
17235
+ # is conservative -- provider_empty_output | rate_limited | auth_error,
17236
+ # else unknown; never fabricates a class that no signal supports.
17237
+ if [ "$exit_code" -ne 130 ] && [ "$exit_code" -ne 143 ] && [ "$exit_code" -ne 137 ]; then
17238
+ local _err_class _err_brief
17239
+ _err_class="$(_loki_classify_iteration_error "$iter_output" "${_empty_output:-0}")"
17240
+ case "$_err_class" in
17241
+ provider_empty_output) _err_brief="The provider returned no output (0 bytes) on this iteration -- no work was done." ;;
17242
+ rate_limited) _err_brief="The provider rate-limited the request; the build will wait and retry." ;;
17243
+ auth_error) _err_brief="The provider rejected the request as unauthorized (check your login or API key)." ;;
17244
+ *) _err_brief="Iteration ${ITERATION_COUNT:-?} failed with exit code ${exit_code} (cause not classified)." ;;
17245
+ esac
17246
+ _loki_write_last_error "${ITERATION_COUNT:-0}" "$_err_class" "$_err_brief" || true
17247
+ fi
17248
+
16687
17249
  # Checkpoint failed iteration state (v5.57.0)
16688
17250
  create_checkpoint "iteration-${ITERATION_COUNT} failed (exit=$exit_code)" "iteration-${ITERATION_COUNT}-fail"
16689
17251
 
@@ -16703,7 +17265,15 @@ else:
16703
17265
  wait_time=$rate_limit_wait
16704
17266
  local human_time=$(format_duration $wait_time)
16705
17267
  log_warn "Rate limit detected! Waiting until reset (~$human_time)..."
16706
- log_info "Rate limit resets at approximately $(date -v+${wait_time}S '+%I:%M %p' 2>/dev/null || date -d "+${wait_time} seconds" '+%I:%M %p' 2>/dev/null || echo 'soon')"
17268
+ local _reset_at
17269
+ _reset_at="$(date -v+${wait_time}S '+%I:%M %p' 2>/dev/null || date -d "+${wait_time} seconds" '+%I:%M %p' 2>/dev/null || echo 'soon')"
17270
+ log_info "Rate limit resets at approximately $_reset_at"
17271
+ # T2.7: elevate the wait to an explicit, reassuring INFO line (the
17272
+ # human time was previously only at DEBUG inside detect_rate_limit's
17273
+ # calculated-backoff branch) so a multi-minute wait does not look
17274
+ # like a hang, and persist a machine-readable signal for watchers.
17275
+ log_info "Rate-limited by the provider; waiting ~${wait_time}s (resets ${_reset_at}). This is normal, not a hang."
17276
+ _loki_write_rate_limit_signal "$wait_time" "$_reset_at" || true
16707
17277
  notify_rate_limit "$wait_time"
16708
17278
  else
16709
17279
  wait_time=$(calculate_wait $retry)
@@ -16743,6 +17313,10 @@ else:
16743
17313
  done
16744
17314
  echo ""
16745
17315
 
17316
+ # T2.7: the wait is over -- clear the RATE_LIMITED signal so it never
17317
+ # lingers stale once the build resumes. Best-effort.
17318
+ rm -f "${TARGET_DIR:-.}/.loki/signals/RATE_LIMITED" 2>/dev/null || true
17319
+
16746
17320
  # Clean up per-iteration output file
16747
17321
  rm -f "$iter_output" 2>/dev/null
16748
17322
 
@@ -17727,8 +18301,15 @@ main() {
17727
18301
  fi
17728
18302
  # Release on session-process exit so a fresh `loki start` can
17729
18303
  # immediately re-acquire after this one finishes / is killed.
18304
+ # T2.6: piggyback the existing lock-release trap (we deliberately do NOT
18305
+ # add a new broad EXIT trap) to write a best-effort terminal record on an
18306
+ # untrapped non-zero exit where the run status is still "running" -- so a
18307
+ # post-crash `loki why` is not stale. _loki_terminal_record is a strict
18308
+ # no-op on every graceful path (status already settled) and never alters
18309
+ # the exit code. SIGKILL/power-loss stay uncatchable (no trap fires); the
18310
+ # ENT-2 durable-resume path covers those.
17730
18311
  # shellcheck disable=SC2064
17731
- trap "safe_release_lock '$lock_file'" EXIT INT TERM HUP
18312
+ trap "_loki_terminal_record || true; safe_release_lock '$lock_file'" EXIT INT TERM HUP
17732
18313
 
17733
18314
  # Check PID file after acquiring lock
17734
18315
  if [ -f "$pid_file" ]; then
@@ -17843,6 +18424,13 @@ main() {
17843
18424
  # resume/rollback sees the restored state. Zero behavior change for local.
17844
18425
  _loki_object_store_hydrate_checkpoints || true
17845
18426
 
18427
+ # Plan #16 (A-1): establish git in an engine-owned, non-git build workspace
18428
+ # BEFORE branch setup + start-SHA capture (both need a resolvable HEAD), so
18429
+ # the per-iteration review/verify gate actually runs instead of skipping on
18430
+ # an empty diff. No-op for the engine source tree, a user's own repo, or a
18431
+ # non-engine-owned folder. See maybe_git_init_engine_workspace.
18432
+ maybe_git_init_engine_workspace
18433
+
17846
18434
  # Setup agent branch protection (isolates agent changes to a feature branch)
17847
18435
  setup_agent_branch
17848
18436
 
@@ -17884,6 +18472,13 @@ main() {
17884
18472
  fi
17885
18473
  fi
17886
18474
 
18475
+ # Clear any stale per-run diagnosis record from a PRIOR run before this one
18476
+ # starts. LAST_ERROR.json is a single side-record; if it survived a previous
18477
+ # failed run it must not surface next to THIS run's outcome (a stale error
18478
+ # shown beside a fresh success would be a fake-green-adjacent lie). Mirrors
18479
+ # the RATE_LIMITED signal clear. Best-effort; never blocks the run.
18480
+ rm -f "${TARGET_DIR:-.}/.loki/state/LAST_ERROR.json" 2>/dev/null || true
18481
+
17887
18482
  if [ "$PARALLEL_MODE" = "true" ]; then
17888
18483
  # Parallel mode: orchestrate multiple worktrees
17889
18484
  log_header "Running in Parallel Mode"
@@ -18133,6 +18728,13 @@ except (json.JSONDecodeError, OSError): pass
18133
18728
  " 2>/dev/null || true
18134
18729
  fi
18135
18730
 
18731
+ # T2.4: on ANY non-zero final result, surface a plain-language next step
18732
+ # (print to stderr + write .loki/NEXT_STEPS.txt). Single chokepoint at the
18733
+ # finalization exit so it fires once and never double-prints on success.
18734
+ if [ "$result" != "0" ]; then
18735
+ _loki_surface_why_hint || true
18736
+ fi
18737
+
18136
18738
  exit $result
18137
18739
  }
18138
18740