loki-mode 7.109.0 → 7.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.109.0
6
+ # Loki Mode v7.111.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -408,4 +408,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
408
408
 
409
409
  ---
410
410
 
411
- **v7.109.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.111.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.109.0
1
+ 7.111.0
@@ -673,8 +673,11 @@ _detect_port() {
673
673
  fi
674
674
  local port
675
675
  # Handle both simple (HOST:CONTAINER) and IP-bound (IP:HOST:CONTAINER) port formats
676
- # Also handle port ranges like "8080-8090:8080-8090" by taking the first port
677
- port=$(grep -E '^\s*-\s*"?[0-9]' "$compose_file" 2>/dev/null | head -1 | sed 's/.*- *"*//;s/".*//;' | awk -F: '{print $(NF-1)}' | awk -F- '{print $1}')
676
+ # Also handle port ranges like "8080-8090:8080-8090" by taking the FIRST port.
677
+ # The leading strip must be anchored (^...) and must NOT use a greedy `.*-`,
678
+ # otherwise it consumes the range's internal dash and yields the LAST port
679
+ # (e.g. "8080-8090:8080-8090" -> 8090 instead of 8080). See wave-3 repro.
680
+ port=$(grep -E '^\s*-\s*"?[0-9]' "$compose_file" 2>/dev/null | head -1 | sed -E 's/^[[:space:]]*-[[:space:]]*"?//; s/".*$//' | awk -F: '{print $(NF-1)}' | awk -F- '{print $1}')
678
681
  _APP_RUNNER_PORT="${port:-8080}"
679
682
  fi
680
683
  ;;
@@ -3103,7 +3103,14 @@ council_evaluate() {
3103
3103
  # Step 3: Unanimous -- run devil's advocate
3104
3104
  local da_result
3105
3105
  da_result=$(council_devils_advocate_review "$ITERATION_COUNT")
3106
- if [ "$da_result" = "OVERRIDE_CONTINUE" ]; then
3106
+ # council_devils_advocate_review emits log_warn/log_info lines to
3107
+ # STDOUT (run.sh log_* echo to stdout, not stderr), so da_result is a
3108
+ # multi-line string whose LAST line is the verdict token. Comparing
3109
+ # the whole capture against "OVERRIDE_CONTINUE" never matched, which
3110
+ # silently defeated the anti-sycophancy backstop. Take the last line.
3111
+ local da_verdict
3112
+ da_verdict="$(printf '%s\n' "$da_result" | tail -n1)"
3113
+ if [ "$da_verdict" = "OVERRIDE_CONTINUE" ]; then
3107
3114
  log_warn "Council evaluate: devil's advocate overrode unanimous COMPLETE"
3108
3115
  # Write transcript: DA triggered and flipped the outcome (Path B)
3109
3116
  council_write_transcript "${ITERATION_COUNT:-0}" "REJECTED" "true" "true" "$_eval_threshold"
@@ -675,8 +675,11 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
675
675
 
676
676
  # ---- v1.1 evidence model -------------------------------------------------
677
677
  # FACTS: deterministic, re-derivable, NON-LLM. A skeptic can recompute every
678
- # one of these from the same .loki state. This is what makes a green receipt
679
- # impossible to forge: the headline is computed ONLY from these facts.
678
+ # one of these from the same .loki state. The headline is computed ONLY from
679
+ # these facts. NOTE: this is NOT tamper-proof against a hand-forger on the
680
+ # unsigned path -- whoever can write the proof can also rewrite the facts and
681
+ # recompute the hash. True non-forgeability requires the neutral signed record
682
+ # (service-held key). See proof-verify.py verify() docstring.
680
683
  git_facts = {
681
684
  "base_sha": os.environ.get("_LOKI_ITER_START_SHA", "").strip(),
682
685
  "head_sha": _git_head_sha(target_dir),
@@ -725,8 +728,11 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
725
728
  }
726
729
 
727
730
  # HONESTY: every fact that is not_run/inconclusive/skipped, surfaced loudly,
728
- # plus a deterministic headline a forger cannot turn green without real
729
- # exit_code:0 evidence and a non-empty diff.
731
+ # plus a deterministic headline derived from the recorded facts (real
732
+ # exit_code:0 evidence and a non-empty diff). On the unsigned path this
733
+ # deters an inconsistent editor, but does NOT stop a consistent hand-forger
734
+ # who rewrites the facts and re-hashes; neutral non-forgeability needs the
735
+ # signed record.
730
736
  degraded = _compute_degraded(facts)
731
737
  headline = _compute_headline(facts, degraded)
732
738
  honesty = {
@@ -235,6 +235,20 @@ def main():
235
235
  "nothing could be verified. The headline is computed only from "
236
236
  "deterministic, re-derivable facts, never from an AI opinion."
237
237
  )
238
+ _line()
239
+ # Honest scope of "verify it yourself". A reviewer CAN recompute the diff and
240
+ # the integrity hash, and proof-verify.py re-derives the headline from the
241
+ # recorded facts to catch an inconsistent edit. But on the unsigned path the
242
+ # recorded test/build facts are produced by Loki and taken at face value:
243
+ # a consistent rewrite of facts + headline is not caught here. Neutral,
244
+ # adversarial non-forgeability (Loki not trusted) needs the signed record.
245
+ _line(
246
+ "Scope: you can recompute the diff and the integrity hash yourself, and "
247
+ "`loki proof verify` re-derives the headline from the recorded facts. On "
248
+ "an unsigned receipt the recorded test/build facts are produced by Loki "
249
+ "and taken at face value; a signed receipt is required for verification "
250
+ "that does not trust Loki."
251
+ )
238
252
  return 0
239
253
 
240
254
 
@@ -2,13 +2,26 @@
2
2
  """Deterministic re-verifier for Loki Mode proof-of-run receipts.
3
3
 
4
4
  Companion to proof-generator.py. The generator writes proof.json with an
5
- integrity hash so a skeptic can prove the JSON was not edited. This module
6
- goes one step further: it re-checks the receipt's recorded FACTS against the
7
- live repo, so a skeptic can prove the facts are STILL TRUE (the diff the
8
- receipt claims still matches what is in git), not merely that the JSON bytes
9
- are unaltered. That re-check is what makes the receipt non-forgeable: you
10
- cannot hand-write a proof.json whose recorded diff survives a re-run against
11
- the repo it claims to describe.
5
+ integrity hash so a skeptic can prove the JSON bytes were not edited since
6
+ they were hashed. This module goes further: it re-checks the receipt's
7
+ recorded FACTS against the live repo, so a skeptic can prove the recorded
8
+ diff STILL matches what is in git, not merely that the JSON bytes are
9
+ unaltered. It also re-derives the honesty headline from the recorded facts
10
+ (see headline_consistent below) to catch an INCONSISTENT edit -- a proof
11
+ whose headline was flipped to VERIFIED while the facts still say not_run.
12
+
13
+ What this does NOT do -- honest scope. On the UNSIGNED path (no valid gpg
14
+ signature) the generator is TRUSTED: the recorded facts are taken at face
15
+ value. A forger who rewrites BOTH the facts AND the headline to a mutually
16
+ consistent lie, then recomputes the integrity hash, still passes every
17
+ check here -- the hash proves only bytes-unedited-since-hashing, and the
18
+ re-derived diff can be made to match a repo the forger controls. Neutral,
19
+ adversarial non-forgeability (the generator is NOT trusted) requires the
20
+ SIGNED record: a detached gpg signature from a key the verifier trusts.
21
+ When gpg_ok is True the generator is not trusted; when gpg_ok is "n/a" it
22
+ is. This module reports that distinction (generator_trusted) rather than
23
+ overclaiming that re-checking the diff makes a receipt non-forgeable. It
24
+ does not.
12
25
 
13
26
  Three checks (mirrors dashboard/audit.py verify-CLI style):
14
27
 
@@ -165,6 +178,61 @@ def _to_int(v, default=0):
165
178
  return default
166
179
 
167
180
 
181
+ # ---------------------------------------------------------------------------
182
+ # headline re-derivation (MUST match proof-generator._compute_headline exactly)
183
+ # ---------------------------------------------------------------------------
184
+
185
+ def _compute_headline(facts, degraded):
186
+ """Deterministic headline re-derived from the recorded facts.
187
+
188
+ MUST match proof-generator.py _compute_headline() byte-for-byte in logic
189
+ (same DRIFT-GUARD contract as _canonical / _diff_sha256_from_stat above).
190
+ We mirror rather than import because proof-generator.py has a hyphen in its
191
+ filename (not a plain import) and this file already mirrors its sibling's
192
+ hashing helpers with the same "MUST match" discipline. If the generator's
193
+ rules change, this copy MUST be updated in lockstep.
194
+
195
+ Redaction note: proof_redact.py only rewrites secret/path substrings inside
196
+ string values; it never touches the status enums, integer counts, exit_code,
197
+ or the shape/length of the degraded list that this function reads, and it
198
+ leaves bool(command) truthy. So re-deriving from the STORED (post-redaction)
199
+ facts yields the identical headline the generator computed pre-redaction.
200
+ """
201
+ tests = facts.get("tests") or {}
202
+ build = facts.get("build") or {}
203
+ git = facts.get("git") or {}
204
+ diff_nonempty = bool((git.get("diff") or {}).get("count"))
205
+
206
+ sec = facts.get("security") or {}
207
+ sec_high = bool(sec.get("ran") and (sec.get("high_active") or 0) > 0)
208
+ any_failed = (
209
+ tests.get("status") == "failed"
210
+ or build.get("status") == "failed"
211
+ or any(g.get("status") == "failed"
212
+ for g in (facts.get("quality_gates") or []))
213
+ or sec_high
214
+ )
215
+ if any_failed:
216
+ return "NOT VERIFIED"
217
+
218
+ tests_verified = (
219
+ tests.get("status") == "verified"
220
+ and bool(tests.get("command"))
221
+ and tests.get("exit_code") == 0
222
+ )
223
+ if tests_verified and not degraded and diff_nonempty:
224
+ return "VERIFIED"
225
+ any_verified = (
226
+ tests.get("status") == "verified"
227
+ or build.get("status") == "verified"
228
+ or any(g.get("status") == "passed"
229
+ for g in (facts.get("quality_gates") or []))
230
+ )
231
+ if any_verified and degraded:
232
+ return "VERIFIED WITH GAPS"
233
+ return "NOT VERIFIED"
234
+
235
+
168
236
  # ---------------------------------------------------------------------------
169
237
  # proof field extraction (schema v1.0 + v1.1 tolerant)
170
238
  # ---------------------------------------------------------------------------
@@ -236,6 +304,31 @@ def _recorded_degraded(proof):
236
304
  return []
237
305
 
238
306
 
307
+ def _recorded_degraded_raw(proof):
308
+ """Return honesty.degraded EXACTLY as recorded (for headline re-derivation).
309
+
310
+ _recorded_degraded coerces items to str for the report; _compute_headline
311
+ only cares whether the list is empty, so we pass the raw list to preserve
312
+ the generator's exact truthiness semantics.
313
+ """
314
+ honesty = proof.get("honesty")
315
+ if isinstance(honesty, dict):
316
+ deg = honesty.get("degraded")
317
+ if isinstance(deg, list):
318
+ return deg
319
+ return []
320
+
321
+
322
+ def _recorded_headline(proof):
323
+ """Return honesty.headline as recorded (stripped str), or None if absent."""
324
+ honesty = proof.get("honesty")
325
+ if isinstance(honesty, dict):
326
+ h = honesty.get("headline")
327
+ if isinstance(h, str) and h.strip():
328
+ return h.strip()
329
+ return None
330
+
331
+
239
332
  # ---------------------------------------------------------------------------
240
333
  # gpg
241
334
  # ---------------------------------------------------------------------------
@@ -316,19 +409,38 @@ def verify(proof_path, repo_dir="."):
316
409
 
317
410
  Returns a dict:
318
411
  {
319
- hash_ok: bool tamper check passed
320
- diff_drift: bool | None True=drift, False=match,
412
+ hash_ok: bool tamper check passed
413
+ diff_drift: bool | None True=drift, False=match,
321
414
  None=could not check
322
- diff_recheck: {recorded, current} the two diff stats compared
323
- gpg_ok: True | False | "n/a" signature verdict
324
- degraded: [str] honesty.degraded from the proof
325
- reason: str why ok is False (when it is)
326
- ok: bool overall verdict
415
+ diff_recheck: {recorded, current} the two diff stats compared
416
+ gpg_ok: True | False | "n/a" signature verdict
417
+ generator_trusted: bool see note below
418
+ headline_consistent: bool | None see note below
419
+ degraded: [str] honesty.degraded from the proof
420
+ reason: str why ok is False (when it is)
421
+ ok: bool overall verdict
327
422
  }
328
423
 
329
- `ok` = hash_ok AND diff_drift is False AND gpg_ok in (True, "n/a").
424
+ `ok` = hash_ok AND diff_drift is False AND gpg_ok in (True, "n/a")
425
+ AND headline_consistent is not False.
330
426
  Note: diff_drift None (unverifiable) makes ok False, by design -- we never
331
427
  report "verified" when the central fact could not be re-checked.
428
+
429
+ generator_trusted: True on the UNSIGNED path (gpg_ok != True), False when a
430
+ valid signature is present (gpg_ok is True). On the unsigned path the facts
431
+ are taken at face value; neutral non-forgeability is NOT guaranteed. This
432
+ field exists so the report can state that honestly even when ok is True (at
433
+ which point `reason` is cleared).
434
+
435
+ headline_consistent: defense-in-depth. We re-derive the honesty headline
436
+ from the RECORDED facts (same logic as proof-generator._compute_headline)
437
+ and compare it to the stored honesty.headline. False means an INCONSISTENT
438
+ edit -- e.g. the headline was flipped to VERIFIED while the facts still say
439
+ not_run. That catches a careless/partial forgery. It does NOT catch a
440
+ CONSISTENT forger who rewrites both the facts AND the headline to a matching
441
+ lie and recomputes the integrity hash: on the unsigned path that still
442
+ passes (generator_trusted stays True). None means we could not re-derive
443
+ (no recorded headline, or no facts to derive from) -- not a failure.
332
444
  """
333
445
  proof = _load_proof(proof_path)
334
446
 
@@ -337,6 +449,8 @@ def verify(proof_path, repo_dir="."):
337
449
  "diff_drift": None,
338
450
  "diff_recheck": {"recorded": None, "current": None},
339
451
  "gpg_ok": "n/a",
452
+ "generator_trusted": True,
453
+ "headline_consistent": None,
340
454
  "degraded": _recorded_degraded(proof),
341
455
  "reason": "",
342
456
  "ok": False,
@@ -366,6 +480,12 @@ def verify(proof_path, repo_dir="."):
366
480
  gpg_sig = verification.get("gpg_signature")
367
481
  result["gpg_ok"] = _verify_gpg(canonical_bytes, gpg_sig)
368
482
 
483
+ # generator_trusted: only a VALID signature (gpg_ok is True) means the
484
+ # generator is NOT trusted (neutral non-forgeability). "n/a" or a bad sig
485
+ # leaves the generator trusted -- the facts are taken at face value and a
486
+ # consistent forger is NOT caught. Stated so ok=True still discloses it.
487
+ result["generator_trusted"] = (result["gpg_ok"] is not True)
488
+
369
489
  # ----- 2. DRIFT CHECK --------------------------------------------------
370
490
  recorded_stat = _recorded_diff_stat(proof)
371
491
  result["diff_recheck"]["recorded"] = recorded_stat
@@ -439,11 +559,37 @@ def verify(proof_path, repo_dir="."):
439
559
  if drift and not result["reason"]:
440
560
  result["reason"] = "recorded diff no longer matches the repo (drift detected)"
441
561
 
562
+ # ----- 4. HEADLINE CONSISTENCY (defense-in-depth) ----------------------
563
+ # Re-derive the headline from the recorded facts and compare to the stored
564
+ # honesty.headline. A mismatch means the headline was edited to disagree
565
+ # with the facts it claims to summarize (an INCONSISTENT forgery, e.g.
566
+ # headline flipped to VERIFIED while facts.tests.status is still not_run).
567
+ # This catches careless/partial tampering only. It does NOT catch a
568
+ # CONSISTENT forger who rewrites both the facts and the headline to a
569
+ # matching lie and recomputes the integrity hash -- on the unsigned path
570
+ # that still passes (see generator_trusted). Neutral non-forgeability needs
571
+ # the signed record, not this check.
572
+ recorded_headline = _recorded_headline(proof)
573
+ facts = proof.get("facts")
574
+ if recorded_headline is not None and isinstance(facts, dict):
575
+ derived = _compute_headline(facts, _recorded_degraded_raw(proof))
576
+ result["headline_consistent"] = (derived == recorded_headline)
577
+ if not result["headline_consistent"] and not result["reason"]:
578
+ result["reason"] = (
579
+ "honesty.headline (%r) disagrees with the headline re-derived "
580
+ "from the recorded facts (%r); the headline was edited to "
581
+ "misrepresent the facts" % (recorded_headline, derived)
582
+ )
583
+ else:
584
+ # No recorded headline, or no facts to re-derive from: cannot check.
585
+ result["headline_consistent"] = None
586
+
442
587
  # ----- overall verdict -------------------------------------------------
443
588
  result["ok"] = bool(
444
589
  result["hash_ok"]
445
590
  and result["diff_drift"] is False
446
591
  and result["gpg_ok"] in (True, "n/a")
592
+ and result["headline_consistent"] is not False
447
593
  )
448
594
  if result["ok"]:
449
595
  result["reason"] = ""
@@ -12,7 +12,7 @@ Patterns implemented (see R1-proof-of-run-PLAN.md REDACTION RULES):
12
12
  - Anthropic keys (sk-ant-...) -> [REDACTED:ANTHROPIC_KEY]
13
13
  - OpenAI-style keys (sk-...) -> [REDACTED:OPENAI_KEY]
14
14
  - Google API keys (AI...) -> [REDACTED:GOOGLE_KEY]
15
- - GitHub tokens (gh[pousr]_) -> [REDACTED:GITHUB_TOKEN]
15
+ - GitHub tokens (gh[pousr]_ and github_pat_) -> [REDACTED:GITHUB_TOKEN]
16
16
  - AWS access key ids (AKIA..) -> [REDACTED:AWS_KEY]
17
17
  - AWS secret access keys -> [REDACTED:AWS_SECRET]
18
18
  - Slack tokens (xox[baprs]-) -> [REDACTED:SLACK_TOKEN]
@@ -59,6 +59,11 @@ def reset_context():
59
59
  _PATTERNS = [
60
60
  # Anthropic keys must precede the generic sk- rule.
61
61
  (re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"), "[REDACTED:ANTHROPIC_KEY]"),
62
+ # GitHub fine-grained PATs (github_pat_...). Must precede the classic
63
+ # gh[pousr]_ rule: "github_pat_" does not match gh[pousr]_ (the char after
64
+ # "gh" is "i"), so the classic rule would leave fine-grained PATs untouched.
65
+ # The token body is a mix of [A-Za-z0-9_], so allow underscores here.
66
+ (re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "[REDACTED:GITHUB_TOKEN]"),
62
67
  # GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_.
63
68
  (re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), "[REDACTED:GITHUB_TOKEN]"),
64
69
  # Slack tokens: xoxb-, xoxa-, xoxp-, xoxr-, xoxs-.
package/autonomy/loki CHANGED
@@ -31119,7 +31119,10 @@ PYEOF
31119
31119
  # canonical proof (tamper check) and re-derives the diff from the
31120
31120
  # recorded base_sha vs live HEAD (drift check). Exit 0 = clean, 1 =
31121
31121
  # tamper/drift, 2 = unusable input. This is the "verify it yourself"
31122
- # path that makes the Evidence Receipt non-forgeable.
31122
+ # path: it re-checks integrity + re-derives the diff from git, so an
31123
+ # inconsistent edit is caught. It does NOT prove non-forgeability on
31124
+ # the unsigned path (a hand-forger can rewrite facts and re-hash);
31125
+ # neutral non-forgeability requires the signed record.
31123
31126
  local id="${1:-}"
31124
31127
  if [ -z "$id" ]; then
31125
31128
  echo -e "${RED}Missing proof id.${NC} Use 'loki proof list'."
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.109.0"
10
+ __version__ = "7.111.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -1180,6 +1180,16 @@ async def get_status() -> StatusResponse:
1180
1180
  if state_file.exists():
1181
1181
  try:
1182
1182
  state = _safe_json_read(state_file, {})
1183
+ # dashboard-state.json is agent/user-written and, under atomic-write
1184
+ # races or hand edits, its top level can be a list/string/number
1185
+ # rather than an object. state.get(...) would then raise
1186
+ # AttributeError, which the surrounding (JSONDecodeError, KeyError)
1187
+ # handler does NOT catch -> /api/status 500s and blanks the board.
1188
+ # Coerce to {} so a bad shape degrades to the idle payload. Placed
1189
+ # BEFORE the _has_dashboard_state flag so a truthy non-dict (e.g.
1190
+ # [1,2,3]) cannot flip it on with corrupt data.
1191
+ if not isinstance(state, dict):
1192
+ state = {}
1183
1193
  if state:
1184
1194
  _has_dashboard_state = True
1185
1195
  phase = state.get("phase", "")
@@ -1188,8 +1198,15 @@ async def get_status() -> StatusResponse:
1188
1198
  mode = state.get("mode", "")
1189
1199
  # Count only agents with alive PIDs (not raw array length)
1190
1200
  agents_list = state.get("agents", [])
1201
+ if not isinstance(agents_list, list):
1202
+ agents_list = []
1191
1203
  running_agents = 0
1192
1204
  for agent in agents_list:
1205
+ # agents entries can be bare strings/None in malformed state;
1206
+ # agent.get(...) would raise AttributeError. Skip non-dicts.
1207
+ if not isinstance(agent, dict):
1208
+ running_agents += 1 # legacy/opaque entry -> count as running
1209
+ continue
1193
1210
  agent_pid = agent.get("pid")
1194
1211
  if agent_pid:
1195
1212
  try:
@@ -1202,10 +1219,17 @@ async def get_status() -> StatusResponse:
1202
1219
  running_agents += 1
1203
1220
 
1204
1221
  tasks = state.get("tasks", {})
1205
- pending_tasks = len(tasks.get("pending", []))
1222
+ if not isinstance(tasks, dict):
1223
+ tasks = {}
1224
+ _pending = tasks.get("pending", [])
1225
+ pending_tasks = len(_pending) if isinstance(_pending, list) else 0
1206
1226
  in_progress = tasks.get("inProgress", [])
1207
- if in_progress:
1208
- current_task = in_progress[0].get("payload", {}).get("action", "")
1227
+ if not isinstance(in_progress, list):
1228
+ in_progress = []
1229
+ if in_progress and isinstance(in_progress[0], dict):
1230
+ _payload = in_progress[0].get("payload", {})
1231
+ if isinstance(_payload, dict):
1232
+ current_task = _payload.get("action", "")
1209
1233
  except (json.JSONDecodeError, KeyError):
1210
1234
  pass
1211
1235
 
@@ -1783,6 +1807,13 @@ async def list_tasks(
1783
1807
  if state_file.exists():
1784
1808
  try:
1785
1809
  state = json.loads(state_file.read_text())
1810
+ # dashboard-state.json top level can itself be a list/string/number
1811
+ # (agent/user-written, atomic-write race). state.get(...) would then
1812
+ # raise AttributeError BEFORE the task_groups guard below, and the
1813
+ # surrounding (JSONDecodeError, KeyError) handler does not catch it
1814
+ # -> /api/tasks 500s and blanks the board. Coerce state first.
1815
+ if not isinstance(state, dict):
1816
+ state = {}
1786
1817
  task_groups = state.get("tasks", {})
1787
1818
  # v7.104.4: dashboard-state.json is user/agent-written and can be
1788
1819
  # malformed or partially written. If "tasks" is not a dict, or a
@@ -6621,6 +6652,11 @@ def _compute_cost_snapshot() -> dict:
6621
6652
  if budget_file.exists():
6622
6653
  try:
6623
6654
  budget_data = json.loads(budget_file.read_text())
6655
+ # budget.json can be a bare number/list/string; guard before .get()
6656
+ # so the cost-summary endpoint degrades instead of raising an
6657
+ # AttributeError uncaught by (JSONDecodeError, KeyError).
6658
+ if not isinstance(budget_data, dict):
6659
+ budget_data = {}
6624
6660
  budget_limit = budget_data.get("limit")
6625
6661
  if budget_limit is not None:
6626
6662
  budget_used = estimated_cost
@@ -6664,6 +6700,12 @@ async def get_budget():
6664
6700
  if budget_file.exists():
6665
6701
  try:
6666
6702
  budget_data = json.loads(budget_file.read_text())
6703
+ # budget.json can be a bare number/list/string (agent/user-written
6704
+ # or atomic-write race), so budget_data.get(...) would raise
6705
+ # AttributeError, uncaught by (JSONDecodeError, KeyError) -> 500.
6706
+ # Coerce to {} so the endpoint degrades to no-limit defaults.
6707
+ if not isinstance(budget_data, dict):
6708
+ budget_data = {}
6667
6709
  budget_limit = budget_data.get("limit") or budget_data.get("budget_limit")
6668
6710
  budget_used = budget_data.get("budget_used", 0.0)
6669
6711
  exceeded = budget_data.get("exceeded", False)
@@ -6687,6 +6729,9 @@ async def get_budget():
6687
6729
  if exceeded_at is None:
6688
6730
  try:
6689
6731
  sig_data = json.loads(signal_file.read_text())
6732
+ # Signal file may parse to a non-dict; guard before .get().
6733
+ if not isinstance(sig_data, dict):
6734
+ sig_data = {}
6690
6735
  exceeded_at = sig_data.get("timestamp")
6691
6736
  except (json.JSONDecodeError, KeyError):
6692
6737
  pass
@@ -10935,9 +10980,17 @@ async def proofs_summary():
10935
10980
  """Honest aggregate over the active project's Evidence Receipts.
10936
10981
 
10937
10982
  Counts are computed ONLY from real proof.json files; nothing is invented.
10938
- The single source of truth for "verified" is the v1.1 deterministic
10939
- honesty.headline (proof-generator.py::_compute_headline), which a forger
10940
- cannot turn green without real exit_code:0 evidence. Buckets:
10983
+ Each proof is bucketed on its recorded honesty.headline, computed by the
10984
+ deterministic proof-generator.py::_compute_headline (never an LLM opinion).
10985
+ Honest scope of that guarantee: the integrity hash proves the JSON bytes
10986
+ were not edited since they were hashed, and proof-verify.py re-derives the
10987
+ git diff so a skeptic can confirm the recorded diff still matches the repo.
10988
+ But on the UNSIGNED path the generator is TRUSTED -- a forger who rewrites
10989
+ both the facts and the headline to a mutually consistent lie and recomputes
10990
+ the hash still buckets as verified here. Neutral, adversarial non-forgeability
10991
+ (the generator is not trusted) requires the SIGNED record (a gpg signature
10992
+ proof-verify.py checks). This endpoint reports what the generator recorded;
10993
+ it does not itself re-verify. Buckets:
10941
10994
 
10942
10995
  verified -> honesty.headline == "VERIFIED"
10943
10996
  with_gaps -> honesty.headline == "VERIFIED WITH GAPS"
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.109.0
5
+ **Version:** v7.111.0
6
6
 
7
7
  ---
8
8
 
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var X8=Object.defineProperty;var K8=($)=>$;function q8($,Q){this[$]=K8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)X8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:q8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var v1={};b(v1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as r,dirname as i$}from"path";import{fileURLToPath as J8}from"url";import{existsSync as x$}from"fs";import{homedir as V8}from"os";function W8(){let $=y1;for(let Q=0;Q<6;Q++){if(x$(r($,"VERSION"))&&x$(r($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return r(y1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(x$(r(Q,"VERSION"))&&x$(r(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return r($,"..","..","..")}function P(){return process.env.LOKI_DIR??r(process.cwd(),".loki")}function T$(){return r(V8(),".loki")}var y1,h;var C=L(()=>{y1=i$(J8(import.meta.url));h=W8()});import{readFileSync as U8}from"fs";import{resolve as H8,dirname as G8}from"path";import{fileURLToPath as B8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.109.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=G8(B8(import.meta.url)),Z=e$(Q);Q$=U8(H8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var c1={};b(c1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>u1,commandVersion:()=>S8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>f1});async function u1($,Q=f1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([u1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=N8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function N8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function S8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var f1=16777216,Q1;var d=L(()=>{Q1=class Q1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function t($){return D8?"":$}var D8,T,S,_,nZ,I,k,y,J;var c=L(()=>{D8=(process.env.NO_COLOR??"").length>0;T=t("\x1B[0;31m"),S=t("\x1B[0;32m"),_=t("\x1B[1;33m"),nZ=t("\x1B[0;34m"),I=t("\x1B[0;36m"),k=t("\x1B[1m"),y=t("\x1B[2m"),J=t("\x1B[0m")});import{existsSync as c8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(c8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var J0={};b(J0,{runStatus:()=>U3});import{existsSync as v,readFileSync as U$,readdirSync as e1,statSync as $0}from"fs";import{resolve as D,basename as Q3}from"path";import{homedir as Z3}from"os";function Q0($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function Z0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=Q0($),U=Q0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function X3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
2
+ var X8=Object.defineProperty;var K8=($)=>$;function q8($,Q){this[$]=K8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)X8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:q8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var v1={};b(v1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as r,dirname as i$}from"path";import{fileURLToPath as J8}from"url";import{existsSync as x$}from"fs";import{homedir as V8}from"os";function W8(){let $=y1;for(let Q=0;Q<6;Q++){if(x$(r($,"VERSION"))&&x$(r($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return r(y1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(x$(r(Q,"VERSION"))&&x$(r(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return r($,"..","..","..")}function P(){return process.env.LOKI_DIR??r(process.cwd(),".loki")}function T$(){return r(V8(),".loki")}var y1,h;var C=L(()=>{y1=i$(J8(import.meta.url));h=W8()});import{readFileSync as U8}from"fs";import{resolve as H8,dirname as G8}from"path";import{fileURLToPath as B8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.111.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=G8(B8(import.meta.url)),Z=e$(Q);Q$=U8(H8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var c1={};b(c1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>u1,commandVersion:()=>S8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>f1});async function u1($,Q=f1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([u1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=N8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function N8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function S8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var f1=16777216,Q1;var d=L(()=>{Q1=class Q1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function t($){return D8?"":$}var D8,T,S,_,nZ,I,k,y,J;var c=L(()=>{D8=(process.env.NO_COLOR??"").length>0;T=t("\x1B[0;31m"),S=t("\x1B[0;32m"),_=t("\x1B[1;33m"),nZ=t("\x1B[0;34m"),I=t("\x1B[0;36m"),k=t("\x1B[1m"),y=t("\x1B[2m"),J=t("\x1B[0m")});import{existsSync as c8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(c8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var J0={};b(J0,{runStatus:()=>U3});import{existsSync as v,readFileSync as U$,readdirSync as e1,statSync as $0}from"fs";import{resolve as D,basename as Q3}from"path";import{homedir as Z3}from"os";function Q0($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function Z0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=Q0($),U=Q0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function X3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -805,4 +805,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
805
805
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
806
806
  `),process.stderr.write(z8),2}}i1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var EZ=await RZ(Bun.argv.slice(2));process.exit(EZ);
807
807
 
808
- //# debugId=68DF9A47D3B818A364756E2164756E21
808
+ //# debugId=75C1D788DA07ECAB64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.109.0'
60
+ __version__ = '7.111.0'
package/mcp/lsp_proxy.py CHANGED
@@ -1478,6 +1478,95 @@ def write_diagnostics_artifact(root: Optional[str] = None,
1478
1478
  'count_warnings': count_warnings}
1479
1479
 
1480
1480
 
1481
+ def _run_http_transport(port: int) -> None:
1482
+ """Run the LSP-proxy MCP server over Streamable HTTP, bound to loopback.
1483
+
1484
+ Mirrors mcp/server.py:_run_http_transport for consistency. FastMCP has no
1485
+ 'http' transport literal (it uses 'streamable-http') and run() takes no
1486
+ port= kwarg, so the old mcp.run(transport='http', port=...) call never
1487
+ worked (TypeError). We build the Streamable-HTTP ASGI app ourselves, bind
1488
+ 127.0.0.1 explicitly, and run it via uvicorn -- the same supported path.
1489
+
1490
+ Two hardening properties over a bare FastMCP HTTP launch:
1491
+
1492
+ 1. Explicit loopback bind. We set host='127.0.0.1' ourselves and run the
1493
+ ASGI app via uvicorn rather than relying on any implicit default, so a
1494
+ reader of `lsof`/`ss` sees 127.0.0.1 unambiguously and a future SDK
1495
+ default change cannot silently widen the bind to 0.0.0.0.
1496
+
1497
+ 2. Optional bearer-token auth. When LOKI_MCP_AUTH_TOKEN is set in the
1498
+ environment, a Starlette middleware requires every HTTP request to
1499
+ carry `Authorization: Bearer <token>` (rejecting mismatches with 401).
1500
+ When the env var is unset or empty, NO middleware is installed at all,
1501
+ so the request path is exactly the unauthenticated FastMCP behavior.
1502
+
1503
+ hasattr-guarded against the whole supported mcp 1.x range: if the installed
1504
+ SDK lacks streamable_http_app, we fail with a clear message rather than an
1505
+ AttributeError.
1506
+ """
1507
+ if not hasattr(mcp, "streamable_http_app"):
1508
+ logger.error(
1509
+ "Installed MCP SDK does not support Streamable HTTP transport "
1510
+ "(no FastMCP.streamable_http_app). Upgrade the 'mcp' package "
1511
+ "(pip install -U mcp) or use the default stdio transport."
1512
+ )
1513
+ sys.exit(1)
1514
+
1515
+ host = "127.0.0.1"
1516
+
1517
+ # Pin host/port on the SDK settings too, so any code that reads them (and
1518
+ # the SDK's own transport-security allowed_hosts, which defaults to
1519
+ # 127.0.0.1/localhost) stays consistent with what uvicorn actually binds.
1520
+ try:
1521
+ if hasattr(mcp, "settings"):
1522
+ if hasattr(mcp.settings, "host"):
1523
+ mcp.settings.host = host
1524
+ if hasattr(mcp.settings, "port"):
1525
+ mcp.settings.port = port
1526
+ except Exception as _set_err: # pragma: no cover - defensive
1527
+ logger.warning("Could not pin MCP host/port settings: %s", _set_err)
1528
+
1529
+ app = mcp.streamable_http_app()
1530
+
1531
+ token = os.environ.get("LOKI_MCP_AUTH_TOKEN", "")
1532
+ if token:
1533
+ # Only import the middleware pieces when auth is actually requested, so
1534
+ # the unauthenticated path has zero new dependencies or behavior.
1535
+ from starlette.middleware.base import BaseHTTPMiddleware
1536
+ from starlette.responses import JSONResponse
1537
+
1538
+ expected_header = "Bearer " + token
1539
+
1540
+ async def _require_bearer(request, call_next):
1541
+ # BaseHTTPMiddleware auto-forwards non-http scopes (lifespan etc.),
1542
+ # so the StreamableHTTPSessionManager lifespan still starts.
1543
+ provided = request.headers.get("authorization", "")
1544
+ # Constant-time compare to avoid leaking the token via timing.
1545
+ import hmac
1546
+ if not hmac.compare_digest(provided, expected_header):
1547
+ return JSONResponse(
1548
+ {"error": "unauthorized"},
1549
+ status_code=401,
1550
+ headers={"WWW-Authenticate": "Bearer"},
1551
+ )
1552
+ return await call_next(request)
1553
+
1554
+ app.add_middleware(BaseHTTPMiddleware, dispatch=_require_bearer)
1555
+ logger.info(
1556
+ "MCP HTTP auth enabled: bearer token required "
1557
+ "(LOKI_MCP_AUTH_TOKEN is set)."
1558
+ )
1559
+ else:
1560
+ logger.info(
1561
+ "MCP HTTP auth disabled: no LOKI_MCP_AUTH_TOKEN set "
1562
+ "(loopback-only, unauthenticated)."
1563
+ )
1564
+
1565
+ import uvicorn
1566
+ logger.info("MCP Streamable HTTP listening on http://%s:%d/mcp", host, port)
1567
+ uvicorn.run(app, host=host, port=port, log_level="info")
1568
+
1569
+
1481
1570
  def main() -> None:
1482
1571
  import argparse
1483
1572
  parser = argparse.ArgumentParser(
@@ -1536,7 +1625,11 @@ def main() -> None:
1536
1625
  detected = _detect_lsps()
1537
1626
  logger.info("Detected LSPs: %s", sorted(detected.keys()) or 'none')
1538
1627
  if args.transport == 'http':
1539
- mcp.run(transport='http', port=args.port)
1628
+ # Explicit loopback bind + optional bearer-token auth. FastMCP has no
1629
+ # 'http' transport literal (it uses 'streamable-http') and run() takes
1630
+ # no port= kwarg, so the old mcp.run(transport='http', port=...) call
1631
+ # never worked (TypeError); _run_http_transport is the supported path.
1632
+ _run_http_transport(args.port)
1540
1633
  else:
1541
1634
  mcp.run(transport='stdio')
1542
1635
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.109.0",
4
+ "version": "7.111.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.109.0",
5
+ "version": "7.111.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",
@@ -58,9 +58,9 @@ _aider_default_from_catalog() {
58
58
  if [ -f "${script_dir}/models.sh" ]; then
59
59
  # shellcheck source=./models.sh
60
60
  source "${script_dir}/models.sh"
61
- loki_latest_model aider development 2>/dev/null || echo "claude-opus-4-7"
61
+ loki_latest_model aider development 2>/dev/null || echo "claude-opus-4-8"
62
62
  else
63
- echo "claude-opus-4-7"
63
+ echo "claude-opus-4-8"
64
64
  fi
65
65
  }
66
66
  AIDER_DEFAULT_MODEL="${LOKI_AIDER_MODEL:-${LOKI_MODEL_DEVELOPMENT:-$(_aider_default_from_catalog)}}"
@@ -48,7 +48,7 @@ PROVIDER_MAX_PARALLEL=10
48
48
  # The Claude Code CLI resolves aliases (opus/sonnet/haiku) to the latest available
49
49
  # model at invocation time, so we pass aliases rather than dated IDs. The canonical
50
50
  # mapping lives in providers/model_catalog.json (single source of truth):
51
- # opus -> latest Opus (e.g. claude-opus-4-7 -- 1M context, adaptive thinking)
51
+ # opus -> latest Opus (e.g. claude-opus-4-8 -- 1M context, adaptive thinking)
52
52
  # sonnet -> latest Sonnet (e.g. claude-sonnet-4-6)
53
53
  # haiku -> latest Haiku (e.g. claude-haiku-4-5)
54
54
  # Override per tier with LOKI_CLAUDE_MODEL_PLANNING, _DEVELOPMENT, _FAST.
@@ -59,9 +59,9 @@ _cline_default_from_catalog() {
59
59
  if [ -f "${script_dir}/models.sh" ]; then
60
60
  # shellcheck source=./models.sh
61
61
  source "${script_dir}/models.sh"
62
- loki_latest_model cline development 2>/dev/null || echo "claude-opus-4-7"
62
+ loki_latest_model cline development 2>/dev/null || echo "claude-opus-4-8"
63
63
  else
64
- echo "claude-opus-4-7"
64
+ echo "claude-opus-4-8"
65
65
  fi
66
66
  }
67
67
  CLINE_DEFAULT_MODEL="${LOKI_CLINE_MODEL:-${LOKI_MODEL_DEVELOPMENT:-$(_cline_default_from_catalog)}}"
@@ -234,21 +234,29 @@ provider_invoke_with_tier() {
234
234
  *) model="$PROVIDER_MODEL_DEVELOPMENT" ;;
235
235
  esac
236
236
 
237
- local extra_flags=()
237
+ # --search is a TOP-LEVEL `codex` flag, not a `codex exec` flag. On codex
238
+ # 0.141.0 `codex exec ... --search` aborts with "unexpected argument
239
+ # '--search' found", silently breaking LOKI_CODEX_WEB_SEARCH. It must be
240
+ # placed before the `exec` subcommand. --output-last-message (-o) IS an
241
+ # `codex exec` flag and stays after `exec`. Keep the two in separate arrays.
242
+ local pre_exec_flags=()
238
243
  if [ "${LOKI_CODEX_WEB_SEARCH:-false}" = "true" ]; then
239
- extra_flags+=(--search)
244
+ pre_exec_flags+=(--search)
240
245
  fi
246
+ local extra_flags=()
241
247
  if [ "${LOKI_CODEX_OUTPUT_LAST:-true}" != "false" ] && [ -n "${LOKI_LOG_FILE:-}" ]; then
242
248
  extra_flags+=(--output-last-message "${LOKI_LOG_FILE}.last-message")
243
249
  fi
244
250
 
245
251
  LOKI_CODEX_REASONING_EFFORT="$effort" \
246
252
  CODEX_MODEL_REASONING_EFFORT="$effort" \
247
- # Guard the extra_flags array expansion: with no web-search / output-last
248
- # knobs the array is empty, and a bare "${arr[@]}" under `set -u` aborts with
253
+ # Guard the array expansions: with no web-search / output-last knobs an
254
+ # array is empty, and a bare "${arr[@]}" under `set -u` aborts with
249
255
  # "unbound variable" on bash 3.2 (stock macOS /bin/bash). ${arr[@]+...}
250
256
  # expands to nothing when empty and preserves spaced elements otherwise.
251
- codex exec \
257
+ codex \
258
+ "${pre_exec_flags[@]+"${pre_exec_flags[@]}"}" \
259
+ exec \
252
260
  --sandbox workspace-write \
253
261
  --skip-git-repo-check \
254
262
  --model "$model" \
@@ -8,7 +8,7 @@
8
8
  #
9
9
  # Usage:
10
10
  # source providers/models.sh
11
- # model=$(loki_latest_model claude planning) # -> claude-opus-4-7
11
+ # model=$(loki_latest_model claude planning) # -> claude-opus-4-8
12
12
  #
13
13
  # Env override order: LOKI_<PROVIDER>_MODEL_<TIER> > LOKI_<PROVIDER>_MODEL > catalog latest.
14
14