loki-mode 8.6.1 → 8.8.1

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.1
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.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 8.6.1
1
+ 8.8.1
@@ -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