loki-mode 8.6.1 → 8.8.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 v8.6.1
6
+ # Loki Mode v8.8.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -469,4 +469,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
469
469
 
470
470
  ---
471
471
 
472
- **v8.6.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v8.8.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 8.6.1
1
+ 8.8.0
@@ -254,6 +254,14 @@ def _process_snapshot() -> dict[int, _ProcessRecord]:
254
254
  return snapshot
255
255
 
256
256
 
257
+ class _LineageUnknown(Exception):
258
+ """The lineage marker could not be read because the process is gone.
259
+
260
+ Distinct from "the marker is absent". A vanished process is not evidence
261
+ of a lineage violation -- it is the absence of evidence either way.
262
+ """
263
+
264
+
257
265
  def _process_has_lineage_token(pid: int, token: str) -> bool:
258
266
  needle = f"{_LINEAGE_ENV_NAME}={token}".encode("ascii")
259
267
  if sys.platform == "darwin":
@@ -274,6 +282,19 @@ def _process_has_lineage_token(pid: int, token: str) -> bool:
274
282
  try:
275
283
  with open(f"/proc/{pid}/environ", "rb") as handle:
276
284
  return needle in handle.read().split(b"\0")
285
+ except FileNotFoundError:
286
+ # The process is already gone. /proc/<pid>/environ disappears the
287
+ # moment a child is reaped, so a short-lived runner that exits
288
+ # before this check runs is indistinguishable here from one that
289
+ # never carried the marker -- and answering "no marker" for it
290
+ # fails the lineage guard and returns 127 as a LAUNCH failure, for
291
+ # a process that in fact launched and completed.
292
+ #
293
+ # Signal "unknown", not "absent". The caller decides, because only
294
+ # the caller knows whether the process it spawned is still alive.
295
+ # macOS never hit this: it has the pipe-handle fallback, so the
296
+ # bug was Linux-only and invisible on a developer Mac.
297
+ raise _LineageUnknown(pid) from None
277
298
  except OSError:
278
299
  return False
279
300
  return False
@@ -354,10 +375,23 @@ class _LineageTracker:
354
375
  root = _process_record(process.pid)
355
376
  if root is None:
356
377
  raise RuntimeError("provider attempt identity is unavailable")
357
- if not (
358
- _process_has_lineage_token(root.pid, token)
359
- or _process_has_lineage_pipe(root.pid, lineage_pipe_handles)
360
- ):
378
+ try:
379
+ has_marker = (
380
+ _process_has_lineage_token(root.pid, token)
381
+ or _process_has_lineage_pipe(root.pid, lineage_pipe_handles)
382
+ )
383
+ except _LineageUnknown:
384
+ # The child exited before we could read its marker. That is a
385
+ # completed run, not a lineage violation -- and it is the common
386
+ # case for any runner that finishes fast. Confirm it really is our
387
+ # child (Popen.poll() is authoritative: it reaps only the process
388
+ # this object spawned) before accepting.
389
+ #
390
+ # This stays fail-closed for the case the guard exists to catch: a
391
+ # LIVE process whose marker is genuinely missing still raises,
392
+ # because poll() returns None for it and we fall through.
393
+ has_marker = process.poll() is not None
394
+ if not has_marker:
361
395
  raise RuntimeError("provider attempt lineage marker is unavailable")
362
396
  self.root_pid = root.pid
363
397
  self.session_id = root.session_id
package/autonomy/loki CHANGED
@@ -61,6 +61,16 @@ if [ -f "$_LOKI_SCRIPT_DIR/tui.sh" ]; then
61
61
  source "$_LOKI_SCRIPT_DIR/tui.sh"
62
62
  fi
63
63
 
64
+ # Portable lock helper -- provides safe_acquire_lock / safe_release_lock.
65
+ # Needed here (not just in run.sh) because ensure_dashboard_venv tears down and
66
+ # rebuilds the HOST-GLOBAL ~/.loki/dashboard-venv, so two concurrent CLI
67
+ # invocations can otherwise delete the venv the other is importing from.
68
+ # Self-guarded against double-source.
69
+ if [ -f "$_LOKI_SCRIPT_DIR/lib/lock.sh" ]; then
70
+ # shellcheck source=lib/lock.sh
71
+ source "$_LOKI_SCRIPT_DIR/lib/lock.sh"
72
+ fi
73
+
64
74
  # Crash-reporting helpers (provides loki_collection_enabled, used by
65
75
  # cmd_telemetry status and cmd_crash). Self-guarded against double-source.
66
76
  if [ -f "$_LOKI_SCRIPT_DIR/crash.sh" ]; then
@@ -446,6 +456,33 @@ ensure_dashboard_venv() {
446
456
  return 0
447
457
  fi
448
458
 
459
+ # The venv is HOST-GLOBAL, so concurrent runs must not rebuild it at once:
460
+ # one run's `rm -rf` would delete the tree another is importing from. Lock
461
+ # the venv path itself -- safe_acquire_lock appends ".lockdir", giving a
462
+ # SIBLING mutex that the teardown below cannot destroy. Timeout must outlast
463
+ # a real cold `python3 -m venv` + `pip install` (not the 5s used by the
464
+ # JSON read-modify-write call sites).
465
+ local _venv_locked=false
466
+ if type safe_acquire_lock >/dev/null 2>&1; then
467
+ if safe_acquire_lock "$dashboard_venv" "${LOKI_VENV_LOCK_TIMEOUT:-300}"; then
468
+ _venv_locked=true
469
+ # The winner may have finished the build while we waited -- re-probe
470
+ # before tearing anything down.
471
+ DASHBOARD_PYTHON="python3"
472
+ [ -x "${dashboard_venv}/bin/python3" ] && DASHBOARD_PYTHON="${dashboard_venv}/bin/python3"
473
+ if "$DASHBOARD_PYTHON" -c "import fastapi; import sqlalchemy; import aiosqlite" 2>/dev/null; then
474
+ safe_release_lock "$dashboard_venv"
475
+ return 0
476
+ fi
477
+ else
478
+ # Do NOT fall through into the rm -rf unlocked -- that is the exact
479
+ # race this lock exists to prevent.
480
+ echo -e "${RED}Timed out waiting for another run to finish building the dashboard venv${NC}"
481
+ echo " Retry, or remove a stale lock: rm -rf ${dashboard_venv}.lockdir"
482
+ return 1
483
+ fi
484
+ fi
485
+
449
486
  echo -e "${YELLOW}Setting up dashboard virtualenv...${NC}"
450
487
 
451
488
  # Create venv if missing or broken
@@ -461,6 +498,7 @@ ensure_dashboard_venv() {
461
498
  echo "You may need to install python3-venv:"
462
499
  echo " sudo apt install python3-venv (Debian/Ubuntu)"
463
500
  echo " brew install python3 (macOS)"
501
+ [ "$_venv_locked" = true ] && safe_release_lock "$dashboard_venv"
464
502
  return 1
465
503
  }
466
504
  fi
@@ -485,6 +523,7 @@ ensure_dashboard_venv() {
485
523
  if ! "${dashboard_venv}/bin/pip" install fastapi uvicorn pydantic websockets sqlalchemy aiosqlite httpx pexpect watchdog 2>&1 | tail -1; then
486
524
  echo -e "${RED}Failed to install dashboard dependencies${NC}"
487
525
  echo "Try manually: ${dashboard_venv}/bin/pip install fastapi uvicorn sqlalchemy aiosqlite"
526
+ [ "$_venv_locked" = true ] && safe_release_lock "$dashboard_venv"
488
527
  return 1
489
528
  fi
490
529
  # Try greenlet separately (optional, needs C compiler on some platforms)
@@ -497,9 +536,11 @@ ensure_dashboard_venv() {
497
536
  echo "Try removing the venv and retrying:"
498
537
  echo " rm -rf ${dashboard_venv}"
499
538
  echo " loki dashboard start"
539
+ [ "$_venv_locked" = true ] && safe_release_lock "$dashboard_venv"
500
540
  return 1
501
541
  fi
502
542
 
543
+ [ "$_venv_locked" = true ] && safe_release_lock "$dashboard_venv"
503
544
  return 0
504
545
  }
505
546
 
@@ -5337,7 +5378,14 @@ cmd_provider_models() {
5337
5378
  echo -e "${BOLD}Model Configuration (resolved):${NC}"
5338
5379
  echo ""
5339
5380
 
5340
- local providers=("claude" "codex" "cline" "aider")
5381
+ echo -e " ${CYAN}Ask for a tier by capability -- small, medium or high -- and each"
5382
+ echo -e " provider supplies its own latest model in that class. medium is the"
5383
+ echo -e " default. Set one with LOKI_SESSION_MODEL=small or --session-model small.${NC}"
5384
+ echo ""
5385
+
5386
+ # opencode ships a provider file and a catalog entry, so omitting it here
5387
+ # under-reported what this command claims to show ("all providers").
5388
+ local providers=("claude" "codex" "cline" "aider" "opencode")
5341
5389
  for provider in "${providers[@]}"; do
5342
5390
  local provider_file="$script_dir/providers/${provider}.sh"
5343
5391
  [ -f "$provider_file" ] || continue
@@ -5407,7 +5455,34 @@ cmd_provider_models() {
5407
5455
  fast) value="$fast" ;;
5408
5456
  esac
5409
5457
 
5410
- printf " %-12s %-30s (source: %s)\n" "${tier^}:" "$value" "$source"
5458
+ # Label each tier with the GENERIC vocabulary users actually select
5459
+ # (small|medium|high), so this command answers "what do I really get
5460
+ # if I ask for medium on this provider" without anyone having to know
5461
+ # a vendor model name. The canonical tier name stays alongside it
5462
+ # because every env var, log line and state file still uses it -- and
5463
+ # a user reading LOKI_CLAUDE_MODEL_DEVELOPMENT needs to see the two
5464
+ # spellings connected. Mapping mirrors loki_tier_alias() in
5465
+ # providers/models.sh.
5466
+ local generic
5467
+ case "$tier" in
5468
+ fast) generic="small" ;;
5469
+ development) generic="medium" ;;
5470
+ planning) generic="high" ;;
5471
+ *) generic="$tier" ;;
5472
+ esac
5473
+
5474
+ # An EMPTY resolved model is a real, supported state, not a lookup
5475
+ # failure: providers/codex.sh sets CODEX_DEFAULT_MODEL="" on purpose
5476
+ # so no --model flag is passed and Codex resolves an
5477
+ # account-appropriate default (a hardcoded name broke ChatGPT-account
5478
+ # users outright). Say that plainly instead of printing a blank
5479
+ # column. Deliberately NOT filled in from the catalog: this table
5480
+ # reports what will be DISPATCHED, and the catalog id would be a
5481
+ # guess this file is documented as unable to make.
5482
+ [ -n "$value" ] || value="(provider default -- no --model sent)"
5483
+
5484
+ printf " %-8s %-14s %-38s (source: %s)\n" \
5485
+ "$generic" "(${tier})" "$value" "$source"
5411
5486
  done
5412
5487
 
5413
5488
  # Show extra info per provider
@@ -11341,6 +11416,49 @@ except Exception:
11341
11416
  fi
11342
11417
  echo ""
11343
11418
 
11419
+ # Model catalog freshness. Providers ship models constantly and this catalog
11420
+ # is hand-maintained, so it rots silently -- nothing else in the system ever
11421
+ # reports its age. Reads ONLY the local file's "updated" field: zero network
11422
+ # I/O, so `loki doctor` stays air-gapped-safe (docs/air-gapped.md). We do not
11423
+ # and will not auto-fetch or guess model IDs; inventing a model that does not
11424
+ # exist is worse than being stale, so refresh stays a human-verified step.
11425
+ #
11426
+ # Informational only -- like Runtime route and Cockpit above, it does NOT
11427
+ # touch pass/warn/fail counts and never calls _doctor_block, so a stale
11428
+ # catalog can never flip doctor's exit code or block a build. Advisory by
11429
+ # construction, not by convention.
11430
+ #
11431
+ # Mirrored byte-for-byte in loki-ts/src/commands/doctor.ts -- the bun-parity
11432
+ # matrix diffs this section (it is NOT in the normalizer's strip list), so
11433
+ # edit BOTH routes or parity fails.
11434
+ echo -e "${CYAN}Model catalog:${NC}"
11435
+ _catalog_path="${LOKI_MODEL_CATALOG:-${_LOKI_SCRIPT_DIR}/../providers/model_catalog.json}"
11436
+ _catalog_line=$(LOKI_CATALOG_PATH="$_catalog_path" python3 -c "
11437
+ import json, os, datetime
11438
+ # Threshold: 90 days. The upstream probe workflow runs weekly, so a catalog
11439
+ # untouched for a quarter means the probe PRs are going unread.
11440
+ STALE_DAYS = 90
11441
+ p = os.environ['LOKI_CATALOG_PATH']
11442
+ try:
11443
+ updated = json.load(open(p))['updated']
11444
+ age = (datetime.date.today() - datetime.date.fromisoformat(updated)).days
11445
+ except Exception:
11446
+ print('warn|Catalog unreadable or missing an ISO \"updated\" date -- cannot determine age')
11447
+ else:
11448
+ if age > STALE_DAYS:
11449
+ print(f'warn|Last updated {updated} ({age} days ago) -- may be missing newer models. Refresh: python3 tools/probe-model-catalog.py (reports new model IDs from provider docs; you verify and edit the catalog by hand -- never auto-applied)')
11450
+ else:
11451
+ print(f'pass|Last updated {updated} ({age} days ago)')
11452
+ " 2>/dev/null)
11453
+ # Fail-open: if python3 is missing or the probe dies, say so and move on.
11454
+ [ -n "$_catalog_line" ] || _catalog_line="warn|Could not probe catalog age"
11455
+ if [ "${_catalog_line%%|*}" = "pass" ]; then
11456
+ echo -e " ${GREEN}PASS${NC} ${_catalog_line#*|}"
11457
+ else
11458
+ echo -e " ${YELLOW}WARN${NC} ${_catalog_line#*|}"
11459
+ fi
11460
+ echo ""
11461
+
11344
11462
  # Cockpit capability (E5): report what `loki cockpit` will actually do in
11345
11463
  # this terminal. Informational only (does NOT touch pass/warn/fail counts,
11346
11464
  # like Runtime route above), so the bun-parity matrix stays reconcilable.
@@ -11448,7 +11566,9 @@ except Exception:
11448
11566
  cmd_doctor_json() {
11449
11567
  local _loki_version
11450
11568
  _loki_version=$(get_version)
11451
- LOKI_VERSION="$_loki_version" python3 -c "
11569
+ LOKI_VERSION="$_loki_version" \
11570
+ LOKI_CATALOG_PATH="${LOKI_MODEL_CATALOG:-${_LOKI_SCRIPT_DIR}/../providers/model_catalog.json}" \
11571
+ python3 -c "
11452
11572
  import json, os, subprocess, sys, shutil
11453
11573
 
11454
11574
  def get_version(cmd, args=None):
@@ -11623,6 +11743,32 @@ if _any_provider:
11623
11743
  else:
11624
11744
  fail_count += 1
11625
11745
 
11746
+ # Model catalog freshness. Local read only -- no network. Advisory: deliberately
11747
+ # excluded from pass/fail/warn counts and from 'ok', so a stale catalog can never
11748
+ # flip doctor's exit code. Mirrors describeCatalogFreshness() in
11749
+ # loki-ts/src/commands/doctor.ts.
11750
+ CATALOG_STALE_DAYS = 90
11751
+ _cat_path = os.environ['LOKI_CATALOG_PATH']
11752
+ try:
11753
+ import datetime as _dt
11754
+ _cat_updated = json.load(open(_cat_path))['updated']
11755
+ _cat_age = (_dt.date.today() - _dt.date.fromisoformat(_cat_updated)).days
11756
+ if _cat_age > CATALOG_STALE_DAYS:
11757
+ # Deliberately NOT counted. The block comment above states catalog age is
11758
+ # excluded from pass/fail/warn and from 'ok' so a stale catalog can never
11759
+ # fail a build -- this increment contradicted that contract, and it also
11760
+ # broke route parity: the Bun route (doctor.ts) never counted it, so the
11761
+ # same host reported warnings: 2 on bash and 1 on Bun. bun-parity
11762
+ # compares these byte for byte, so it would have gone red on day 91.
11763
+ model_catalog = {'status': 'warn', 'updated': _cat_updated, 'age_days': _cat_age,
11764
+ 'detail': f'Last updated {_cat_updated} ({_cat_age} days ago) -- may be missing newer models'}
11765
+ else:
11766
+ model_catalog = {'status': 'pass', 'updated': _cat_updated, 'age_days': _cat_age,
11767
+ 'detail': f'Last updated {_cat_updated} ({_cat_age} days ago)'}
11768
+ except Exception:
11769
+ model_catalog = {'status': 'warn', 'updated': None, 'age_days': None,
11770
+ 'detail': 'Catalog unreadable or missing an ISO \"updated\" date -- cannot determine age'}
11771
+
11626
11772
  result = {
11627
11773
  'loki_mode_version': os.environ.get('LOKI_VERSION', 'unknown'),
11628
11774
  'checks': checks,
@@ -11634,6 +11780,7 @@ result = {
11634
11780
  'sentrux': sentrux,
11635
11781
  'receipt_signing': receipt_signing,
11636
11782
  'memory': memory,
11783
+ 'model_catalog': model_catalog,
11637
11784
  'summary': {
11638
11785
  'passed': pass_count,
11639
11786
  'failed': fail_count,
@@ -17159,8 +17306,19 @@ def _loki_norm_alias(raw):
17159
17306
  # pin is a tier route, so tier names ARE valid pins. This normalizer mirrors
17160
17307
  # run.sh's trim+lowercase (interior whitespace preserved, so 'fab le' stays junk
17161
17308
  # and falls through to the default tier exactly like the runner's '*' arm).
17309
+ #
17310
+ # GENERIC TIER VOCABULARY (small|medium|high): translated onto the canonical
17311
+ # tier names before the allowlist check, mirroring run.sh's entry-point case.
17312
+ # The estimator runs in its own process and reads LOKI_SESSION_MODEL directly,
17313
+ # so without this a 'medium' pin would fall through to the '' default and the
17314
+ # quote would silently price a different model than the run dispatches -- the
17315
+ # exact estimator-vs-runner divergence this file keeps paying down. Only the
17316
+ # three new words are translated; every existing value passes through unchanged.
17317
+ _LOKI_GENERIC_TIERS = {'small': 'fast', 'medium': 'development', 'high': 'planning'}
17318
+
17162
17319
  def _loki_norm_session_pin(raw):
17163
17320
  raw = (raw or '').strip().lower()
17321
+ raw = _LOKI_GENERIC_TIERS.get(raw, raw)
17164
17322
  return raw if raw in (
17165
17323
  'haiku', 'sonnet', 'opus', 'fable',
17166
17324
  'planning', 'development', 'fast',
@@ -18128,6 +18286,23 @@ main() {
18128
18286
  local command="$1"
18129
18287
  shift
18130
18288
 
18289
+ # LOKI_HELP_ONLY is set by `loki help <command>`, which delegates by
18290
+ # re-entering dispatch as `loki <command> --help`. Asking for help must
18291
+ # never DO anything, and dispatch reaches commands that start builds, stop
18292
+ # runs and modify the install. Every command honours --help today; this
18293
+ # makes that a guaranteed property of the help path rather than a fact
18294
+ # someone has to re-verify whenever a command is added. If the delegated
18295
+ # invocation somehow lost its --help, refuse rather than execute.
18296
+ if [ "${LOKI_HELP_ONLY:-0}" = "1" ]; then
18297
+ case " $* " in
18298
+ *" --help "*|*" -h "*) : ;;
18299
+ *)
18300
+ echo "loki: refusing to run '$command' from the help path" >&2
18301
+ return 1
18302
+ ;;
18303
+ esac
18304
+ fi
18305
+
18131
18306
  # v7.4.13: first-run telemetry moved to bin/loki shim so it fires for
18132
18307
  # both Bun-routed and bash-routed commands (autonomy/loki main() never
18133
18308
  # runs for the 8 ported commands). Marker file: ~/.loki-first-run.
@@ -18585,6 +18760,22 @@ main() {
18585
18760
  # deprecated-alias table; bare help prints the grouped front page.
18586
18761
  if [ "${1:-}" = "aliases" ]; then
18587
18762
  show_help_aliases
18763
+ elif [ -n "${1:-}" ]; then
18764
+ # `loki help <command>` reaches the command's own help. Before
18765
+ # this, $1 was read only for the literal "aliases" and otherwise
18766
+ # discarded, so `loki help proof` printed the same generic front
18767
+ # page as bare `loki help` -- while `loki proof --help` printed
18768
+ # real help. Both spellings now agree.
18769
+ #
18770
+ # LOKI_HELP_ONLY makes this safe by construction rather than by
18771
+ # audit. Delegation re-enters dispatch, and dispatch reaches
18772
+ # commands that start, stop and modify things. Every one of them
18773
+ # honours --help today, but "asking for help must never DO
18774
+ # anything" is a property worth enforcing at the boundary
18775
+ # instead of re-checking every time a command is added.
18776
+ local _help_target="$1"
18777
+ shift
18778
+ LOKI_HELP_ONLY=1 "$0" "$_help_target" --help "$@"
18588
18779
  else
18589
18780
  show_help
18590
18781
  fi
package/autonomy/run.sh CHANGED
@@ -535,6 +535,43 @@ if [ -n "${LOKI_SESSION_ID:-}" ]; then
535
535
  unset _loki_sid_raw _loki_sid_safe
536
536
  fi
537
537
 
538
+ # GENERIC TIER VOCABULARY (small|medium|high). A user should be able to ask for
539
+ # a capability class without naming a vendor model, and get that provider's
540
+ # latest model in the class. LOKI_SESSION_MODEL is the knob that already does
541
+ # this -- it accepts the raw tier names planning|development|fast alongside the
542
+ # Claude aliases -- so the generic words are normalized ONTO it here rather than
543
+ # becoming a fourth spelling. LOKI_MAX_TIER (a cost CEILING) and LOKI_TIER (the
544
+ # OSS/enterprise licensing seam) mean different things and are left alone.
545
+ #
546
+ # WHY NORMALIZE AT THE ENTRY POINT: the session-pin case block is byte-mirrored
547
+ # in the estimator (autonomy/loki) and the dashboard (dashboard/server.py), and
548
+ # every one of those mirrors is locked by a parity test. Translating here means
549
+ # they keep seeing only the three canonical tier names and none of them change.
550
+ #
551
+ # The mapping is loki_tier_alias() in providers/models.sh -- the single source
552
+ # of truth, not a second copy. Inlined as a case because run.sh must not source
553
+ # a provider file this early in startup. Kept in lockstep by
554
+ # tests/test-generic-tiers.sh.
555
+ #
556
+ # ONLY the three new words are translated. sonnet/haiku/opus/fable and the raw
557
+ # tier names pass through untouched, so an unset LOKI_SESSION_MODEL still
558
+ # defaults to sonnet and "medium" resolves to the same development-tier model
559
+ # today's builds already use. This changes no existing run's model.
560
+ # NORMALIZATION IS TRIM-ONLY + LOWERCASE, matching the estimator and dashboard
561
+ # mirrors exactly. Interior whitespace is deliberately PRESERVED, so " med ium "
562
+ # stays junk here just as it does there. Stripping interior spaces would make
563
+ # this reader accept a value the other two reject, which is the precise kind of
564
+ # divergence the session-pin parity tests exist to catch.
565
+ _loki_generic_tier="${LOKI_SESSION_MODEL:-}"
566
+ _loki_generic_tier="${_loki_generic_tier#"${_loki_generic_tier%%[![:space:]]*}"}"
567
+ _loki_generic_tier="${_loki_generic_tier%"${_loki_generic_tier##*[![:space:]]}"}"
568
+ case "$(printf '%s' "$_loki_generic_tier" | tr '[:upper:]' '[:lower:]')" in
569
+ small) LOKI_SESSION_MODEL="fast" ; export LOKI_SESSION_MODEL ;;
570
+ medium) LOKI_SESSION_MODEL="development" ; export LOKI_SESSION_MODEL ;;
571
+ high) LOKI_SESSION_MODEL="planning" ; export LOKI_SESSION_MODEL ;;
572
+ esac
573
+ unset _loki_generic_tier
574
+
538
575
  # Process Supervision (opt-in)
539
576
  WATCHDOG_ENABLED=${LOKI_WATCHDOG:-"false"} # Enable process health monitoring
540
577
  WATCHDOG_INTERVAL=${LOKI_WATCHDOG_INTERVAL:-30} # Check interval in seconds
@@ -12951,6 +12988,67 @@ with open(os.environ["LOKI_DA_PROMPT_OUT"], "w", encoding="utf-8") as handle:
12951
12988
  BUILD_DA_PROMPT
12952
12989
  }
12953
12990
 
12991
+ # Derive a review size cap (in bytes) from the active provider's context window.
12992
+ #
12993
+ # Args: $1 = env override (wins outright when set), $2 = historical default.
12994
+ # Echoes the effective cap.
12995
+ #
12996
+ # Why this exists: the caps were fixed byte counts sized for a ~200k-token model.
12997
+ # A local 12b/14b with an 8k-32k window would be handed a 425000-byte prompt and
12998
+ # fail in a way that reads as "the model is bad" rather than "we mis-sized it".
12999
+ #
13000
+ # Two stated assumptions, kept separate so they stay auditable:
13001
+ # 1. ~3 bytes per token. Real tokenizers land around 3-4 for code-heavy text;
13002
+ # 3 is the conservative end, and under-estimating capacity errs toward a
13003
+ # smaller cap, which is the safe direction for a fail-closed gate.
13004
+ # 2. ~75% of the window is available for review INPUT. The remainder is the
13005
+ # reviewer's own output and reasoning, which share the same window.
13006
+ # Neither is precise, and neither needs to be: the result is only ever used to
13007
+ # LOWER a cap below the shipped default.
13008
+ #
13009
+ # The min() is load-bearing. PROVIDER_CONTEXT_WINDOW is set on every current run
13010
+ # (LOKI_PROVIDER defaults to claude, whose window is 1000000), so deriving
13011
+ # upward would raise the cap 4-8x for every existing user. Taking the smaller of
13012
+ # derived-vs-default means a cap can only ever move DOWN. Concretely, a window
13013
+ # clamps to the shipped default whenever it is >= ~188889 tokens; every provider
13014
+ # that declares a window today (1M/400k/200k/200k) clears that, and a provider
13015
+ # declaring none takes the unset path, so no shipped provider changes behavior.
13016
+ # Only a genuinely small window (a local 12b/14b) shrinks anything.
13017
+ #
13018
+ # The shipped defaults are 425000 (prompt) and 400000 (diff). That 25000-byte gap
13019
+ # is the reviewer scaffolding wrapped around the diff, and the ordering is
13020
+ # load-bearing: if both caps derived to the SAME number, a diff sized just under
13021
+ # the diff gate would build a prompt exceeding the prompt gate, so every review
13022
+ # would block fail-closed with no operator-visible cause, on exactly the
13023
+ # small-window providers this derivation exists to support. Scaling by
13024
+ # _default/425000 preserves that gap at every window size. The 425000 denominator
13025
+ # must track the prompt-cap default passed by the caller below; if that default
13026
+ # changes, change the denominator with it or the diff-cap proportion breaks.
13027
+ review_effective_cap() {
13028
+ local _override="$1" _default="$2"
13029
+ # Operator wins outright, at any value, over both the default and the window.
13030
+ if [ -n "$_override" ]; then
13031
+ printf '%s' "$_override"
13032
+ return 0
13033
+ fi
13034
+ # Fail safe to the historical default unless the window is a clean positive
13035
+ # integer. A non-numeric result here would make the caller's `[ ... -gt ... ]`
13036
+ # exit 2, which reads as false and would dispatch an oversized review.
13037
+ case "${PROVIDER_CONTEXT_WINDOW:-}" in
13038
+ ''|*[!0-9]*) printf '%s' "$_default"; return 0 ;;
13039
+ esac
13040
+ [ "$PROVIDER_CONTEXT_WINDOW" -gt 0 ] 2>/dev/null || { printf '%s' "$_default"; return 0; }
13041
+ # Derive the INPUT budget, then scale it to this caller's cap so the
13042
+ # prompt/diff proportion (and thus the scaffolding gap) is preserved.
13043
+ local _budget=$(( PROVIDER_CONTEXT_WINDOW * 3 / 4 * 3 ))
13044
+ local _derived=$(( _budget * _default / 425000 ))
13045
+ if [ "$_derived" -lt "$_default" ]; then
13046
+ printf '%s' "$_derived"
13047
+ else
13048
+ printf '%s' "$_default"
13049
+ fi
13050
+ }
13051
+
12954
13052
  run_code_review() {
12955
13053
  local loki_dir="${TARGET_DIR:-.}/.loki"
12956
13054
  local review_dir="$loki_dir/quality/reviews"
@@ -13311,11 +13409,12 @@ ${dependency_context}"
13311
13409
  # produces an opaque block. This remains fail-closed and never truncates.
13312
13410
  local _review_diff_bytes=0
13313
13411
  _review_diff_bytes=$(printf '%s' "$diff_content" | wc -c | tr -d ' ')
13314
- local _review_max_bytes="${LOKI_REVIEW_MAX_DIFF_BYTES:-400000}"
13412
+ local _review_max_bytes
13413
+ _review_max_bytes=$(review_effective_cap "${LOKI_REVIEW_MAX_DIFF_BYTES:-}" 400000)
13315
13414
  if [ "${_review_diff_bytes:-0}" -gt "$_review_max_bytes" ] 2>/dev/null; then
13316
13415
  local _big_dirs
13317
13416
  _big_dirs=$(printf '%s\n' "$changed_files" | sed 's#/.*##' | grep -v '^$' | sort | uniq -c | sort -rn | head -3 | awk '{print $2" ("$1" files)"}' | tr '\n' ' ')
13318
- log_error "Code review: context is ${_review_diff_bytes} bytes (limit ${_review_max_bytes}); refusing to truncate or dispatch a partial review. Biggest dirs: ${_big_dirs:-unknown}. Split the change or raise LOKI_REVIEW_MAX_DIFF_BYTES."
13417
+ log_error "Code review: context is ${_review_diff_bytes} bytes (limit ${_review_max_bytes}, derived from PROVIDER_CONTEXT_WINDOW=${PROVIDER_CONTEXT_WINDOW:-unset}); refusing to truncate or dispatch a partial review. Biggest dirs: ${_big_dirs:-unknown}. Split the change or raise LOKI_REVIEW_MAX_DIFF_BYTES."
13319
13418
  emit_event_json "code_review_diff_oversized" \
13320
13419
  "review_id=$review_id" \
13321
13420
  "diff_bytes=$_review_diff_bytes" \
@@ -13847,7 +13946,8 @@ REVIEW_SELECTION_RECORD
13847
13946
  reviewer_count=$(echo "$selected_specialists" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['reviewers']))")
13848
13947
  local dispatch_count
13849
13948
  dispatch_count=$(echo "$dispatch_specialists" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['reviewers']))")
13850
- local _review_max_prompt_bytes="${LOKI_REVIEW_MAX_PROMPT_BYTES:-425000}"
13949
+ local _review_max_prompt_bytes
13950
+ _review_max_prompt_bytes=$(review_effective_cap "${LOKI_REVIEW_MAX_PROMPT_BYTES:-}" 425000)
13851
13951
  local _review_max_output_bytes="${LOKI_REVIEW_MAX_OUTPUT_BYTES:-1048576}"
13852
13952
  local review_pending_dir="$review_dir/$review_id/.pending"
13853
13953
  if ! mkdir -m 700 "$review_pending_dir" 2>/dev/null; then
@@ -15433,40 +15533,71 @@ start_dashboard() {
15433
15533
 
15434
15534
  # Check all required imports
15435
15535
  if ! "$python_cmd" -c "import fastapi; import sqlalchemy; import aiosqlite" 2>/dev/null; then
15436
- log_step "Setting up dashboard virtualenv..."
15437
- if ! [ -x "${dashboard_venv}/bin/python3" ]; then
15438
- # Remove broken venv if exists
15439
- [ -d "$dashboard_venv" ] && rm -rf "$dashboard_venv"
15440
- mkdir -p "$HOME/.loki"
15441
- python3 -m venv "$dashboard_venv" 2>/dev/null || python3.13 -m venv "$dashboard_venv" 2>/dev/null || {
15442
- log_warn "Failed to create virtualenv"
15443
- log_warn "You may need: sudo apt install python3-venv"
15444
- }
15445
- fi
15446
- if [ -x "${dashboard_venv}/bin/python3" ]; then
15447
- python_cmd="${dashboard_venv}/bin/python3"
15448
- log_step "Installing dashboard dependencies..."
15449
- if [ -f "$req_file" ]; then
15450
- "${dashboard_venv}/bin/pip" install -r "$req_file" 2>&1 | tail -1 || {
15451
- log_warn "Pinned deps failed, trying unpinned..."
15536
+ # The venv is HOST-GLOBAL, so concurrent runs must not rebuild it at
15537
+ # once: one run's `rm -rf` would delete the tree another is importing
15538
+ # from. Lock the venv path itself -- safe_acquire_lock appends
15539
+ # ".lockdir", giving a SIBLING mutex the teardown below cannot destroy.
15540
+ # Timeout must outlast a real cold venv create + pip install (not the 5s
15541
+ # used by the JSON read-modify-write call sites). Mirrors the same guard
15542
+ # in ensure_dashboard_venv (autonomy/loki) -- edit BOTH.
15543
+ local _venv_locked=false
15544
+ if type safe_acquire_lock >/dev/null 2>&1 \
15545
+ && safe_acquire_lock "$dashboard_venv" "${LOKI_VENV_LOCK_TIMEOUT:-300}"; then
15546
+ _venv_locked=true
15547
+ fi
15548
+ # Re-probe: the run we queued behind may have just built it for us.
15549
+ [ -x "${dashboard_venv}/bin/python3" ] && python_cmd="${dashboard_venv}/bin/python3"
15550
+ if "$python_cmd" -c "import fastapi; import sqlalchemy; import aiosqlite" 2>/dev/null; then
15551
+ [ "$_venv_locked" = true ] && safe_release_lock "$dashboard_venv"
15552
+ _venv_locked=false
15553
+ elif [ "$_venv_locked" = false ] && type safe_acquire_lock >/dev/null 2>&1; then
15554
+ # Lock timed out and the venv is still unusable. Do NOT rm -rf
15555
+ # unlocked -- that is the exact race this lock exists to prevent.
15556
+ #
15557
+ # The re-probe above may have pointed python_cmd at the OTHER run's
15558
+ # half-built venv (bin/python3 exists, pip install not finished).
15559
+ # Reset to the system interpreter so the server launch below does not
15560
+ # exec a knowingly-broken one.
15561
+ python_cmd="python3"
15562
+ log_warn "Timed out waiting for another run to build the dashboard venv"
15563
+ log_warn "Dashboard will not be available (stale lock? rm -rf ${dashboard_venv}.lockdir)"
15564
+ else
15565
+ log_step "Setting up dashboard virtualenv..."
15566
+ if ! [ -x "${dashboard_venv}/bin/python3" ]; then
15567
+ # Remove broken venv if exists
15568
+ [ -d "$dashboard_venv" ] && rm -rf "$dashboard_venv"
15569
+ mkdir -p "$HOME/.loki"
15570
+ python3 -m venv "$dashboard_venv" 2>/dev/null || python3.13 -m venv "$dashboard_venv" 2>/dev/null || {
15571
+ log_warn "Failed to create virtualenv"
15572
+ log_warn "You may need: sudo apt install python3-venv"
15573
+ }
15574
+ fi
15575
+ if [ -x "${dashboard_venv}/bin/python3" ]; then
15576
+ python_cmd="${dashboard_venv}/bin/python3"
15577
+ log_step "Installing dashboard dependencies..."
15578
+ if [ -f "$req_file" ]; then
15579
+ "${dashboard_venv}/bin/pip" install -r "$req_file" 2>&1 | tail -1 || {
15580
+ log_warn "Pinned deps failed, trying unpinned..."
15581
+ "${dashboard_venv}/bin/pip" install fastapi uvicorn pydantic websockets sqlalchemy aiosqlite 2>&1 | tail -1 || {
15582
+ log_warn "Failed to install dashboard dependencies"
15583
+ log_warn "Dashboard will not be available"
15584
+ }
15585
+ # greenlet is optional (needs C compiler on some platforms)
15586
+ "${dashboard_venv}/bin/pip" install greenlet 2>/dev/null || true
15587
+ }
15588
+ else
15452
15589
  "${dashboard_venv}/bin/pip" install fastapi uvicorn pydantic websockets sqlalchemy aiosqlite 2>&1 | tail -1 || {
15453
15590
  log_warn "Failed to install dashboard dependencies"
15454
15591
  log_warn "Dashboard will not be available"
15455
15592
  }
15456
- # greenlet is optional (needs C compiler on some platforms)
15457
15593
  "${dashboard_venv}/bin/pip" install greenlet 2>/dev/null || true
15458
- }
15594
+ fi
15459
15595
  else
15460
- "${dashboard_venv}/bin/pip" install fastapi uvicorn pydantic websockets sqlalchemy aiosqlite 2>&1 | tail -1 || {
15461
- log_warn "Failed to install dashboard dependencies"
15462
- log_warn "Dashboard will not be available"
15463
- }
15464
- "${dashboard_venv}/bin/pip" install greenlet 2>/dev/null || true
15596
+ log_warn "Failed to install dashboard dependencies"
15597
+ log_warn "Run manually: python3 -m venv ${dashboard_venv} && ${dashboard_venv}/bin/pip install fastapi uvicorn sqlalchemy aiosqlite"
15465
15598
  fi
15466
- else
15467
- log_warn "Failed to install dashboard dependencies"
15468
- log_warn "Run manually: python3 -m venv ${dashboard_venv} && ${dashboard_venv}/bin/pip install fastapi uvicorn sqlalchemy aiosqlite"
15469
15599
  fi
15600
+ [ "$_venv_locked" = true ] && safe_release_lock "$dashboard_venv"
15470
15601
  fi
15471
15602
 
15472
15603
  # Start the FastAPI dashboard server
@@ -20750,6 +20881,16 @@ except Exception as exc:
20750
20881
  # Trim to last 500KB
20751
20882
  tail -c 500000 "$agent_log" > "$agent_log.tmp" && mv "$agent_log.tmp" "$agent_log"
20752
20883
  fi
20884
+
20885
+ # Same cap on the daily log. agent.log has been trimmed since it was
20886
+ # introduced; its sibling never was, and it receives the full raw
20887
+ # stream-json of every iteration -- measured ~1.5MB per iteration, so a
20888
+ # 500-iteration run leaves ~725MB per day per build, times however many
20889
+ # builds share the machine. Same threshold, same trim, no new rotation
20890
+ # scheme.
20891
+ if [ -f "$log_file" ] && [ "$(stat -f%z "$log_file" 2>/dev/null || stat -c%s "$log_file" 2>/dev/null)" -gt 1000000 ]; then
20892
+ tail -c 500000 "$log_file" > "$log_file.tmp" && mv "$log_file.tmp" "$log_file"
20893
+ fi
20753
20894
  touch "$agent_log"
20754
20895
  echo "" >> "$agent_log"
20755
20896
  echo "════════════════════════════════════════════════════════════════" >> "$agent_log"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "8.6.1"
10
+ __version__ = "8.8.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: