loki-mode 7.40.0 → 7.41.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, 11 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.40.0
6
+ # Loki Mode v7.41.1
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -398,4 +398,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
398
398
 
399
399
  ---
400
400
 
401
- **v7.40.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
401
+ **v7.41.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.40.0
1
+ 7.41.1
@@ -136,6 +136,121 @@ HEALTH_EOF
136
136
  mv "$tmp_file" "$_APP_RUNNER_DIR/health.json"
137
137
  }
138
138
 
139
+ # Re-derive a detection.json field (type/command) so we can rewrite it after a
140
+ # port reconcile without threading those values through globals. Echoes the raw
141
+ # string value (empty on miss). Mirrors the grep-based read style used by
142
+ # app_runner_status.
143
+ _read_detection_field() {
144
+ local field="$1"
145
+ [ -f "$_APP_RUNNER_DIR/detection.json" ] || return 0
146
+ grep -o "\"${field}\": *\"[^\"]*\"" "$_APP_RUNNER_DIR/detection.json" 2>/dev/null \
147
+ | head -1 | sed 's/.*"\([^"]*\)"$/\1/'
148
+ }
149
+
150
+ # Rewrite detection.json with the reconciled port, preserving type/command.
151
+ _rewrite_detection_port() {
152
+ local d_type d_command
153
+ d_type=$(_read_detection_field "type")
154
+ d_command=$(_read_detection_field "command")
155
+ [ -n "$d_type" ] || return 0
156
+ _write_detection "$d_type" "$d_command"
157
+ }
158
+
159
+ # Fix #2 (finding #597): reconcile the recorded port with the port the app
160
+ # ACTUALLY bound, using the listen line in app.log as the source of truth. This
161
+ # corrects the dashboard Live Preview even when the app ignores PORT and picks
162
+ # its own port. Bounded poll: returns as soon as a listen line is found, and
163
+ # never runs for docker (compose URLs come from published-port mapping) or when
164
+ # no port was recorded. Default window LOKI_APP_PORT_RECONCILE_SECS (default 12)
165
+ # at 0.5s intervals. On no match within the window the recorded port is kept (no
166
+ # regression). Stdout: nothing; mutates _APP_RUNNER_PORT / _APP_RUNNER_URL and
167
+ # rewrites state.json + detection.json only when the real port differs.
168
+ _app_runner_reconcile_port() {
169
+ [ "$_APP_RUNNER_IS_DOCKER" != true ] || return 0
170
+ [ -n "$_APP_RUNNER_PORT" ] && [ "$_APP_RUNNER_PORT" -gt 0 ] 2>/dev/null || return 0
171
+ local log_file="$_APP_RUNNER_DIR/app.log"
172
+
173
+ # Fast path: if the recorded port already serves HTTP, the app honored our
174
+ # chosen port (fix #1 worked) or otherwise bound it -- nothing to reconcile,
175
+ # and we avoid the poll latency entirely. Covers quiet-but-serving apps that
176
+ # never log a recognizable listen line.
177
+ if command -v curl >/dev/null 2>&1 && \
178
+ curl -sf -o /dev/null -m 2 "http://localhost:${_APP_RUNNER_PORT}/" 2>/dev/null; then
179
+ return 0
180
+ fi
181
+
182
+ local max_secs="${LOKI_APP_PORT_RECONCILE_SECS:-12}"
183
+ [[ "$max_secs" =~ ^[0-9]+$ ]] || max_secs=12
184
+ local max_iter=$(( max_secs * 2 ))
185
+ [ "$max_iter" -gt 0 ] || max_iter=1
186
+
187
+ local real_port="" iter=0
188
+ while [ "$iter" -lt "$max_iter" ]; do
189
+ if [ -f "$log_file" ]; then
190
+ real_port=$(_parse_listen_port "$log_file")
191
+ [ -n "$real_port" ] && break
192
+ fi
193
+ # Stop early if the process already died (failed start): nothing to wait for.
194
+ if [ -n "$_APP_RUNNER_PID" ] && ! kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
195
+ break
196
+ fi
197
+ sleep 0.5
198
+ iter=$(( iter + 1 ))
199
+ done
200
+
201
+ [ -n "$real_port" ] || return 0
202
+ if [ "$real_port" != "$_APP_RUNNER_PORT" ]; then
203
+ log_info "App Runner: reconciled port $_APP_RUNNER_PORT -> $real_port (from app.log listen line)"
204
+ _APP_RUNNER_PORT="$real_port"
205
+ _APP_RUNNER_URL="http://localhost:${real_port}"
206
+ _rewrite_detection_port
207
+ fi
208
+ return 0
209
+ }
210
+
211
+ # Parse the actual bound port from an app log file. Scans known listen-line
212
+ # shapes in priority order and returns the LAST (most recent) plausible port,
213
+ # tolerating ANSI color codes that dev servers emit. Validates 1-65535. Echoes
214
+ # the port or nothing.
215
+ _parse_listen_port() {
216
+ local file="$1"
217
+ [ -f "$file" ] || return 0
218
+ # Strip ANSI SGR sequences (\e[...m) so color-wrapped URLs still match.
219
+ local clean
220
+ clean=$(sed -E $'s/\x1b\\[[0-9;]*m//g' "$file" 2>/dev/null) || clean=$(cat "$file" 2>/dev/null)
221
+ [ -n "$clean" ] || return 0
222
+
223
+ local candidate=""
224
+ # 1) Explicit URL with a port: http://host:PORT (most reliable).
225
+ candidate=$(printf '%s\n' "$clean" \
226
+ | grep -oiE 'https?://[a-z0-9.\-]+:[0-9]{1,5}' \
227
+ | grep -oE ':[0-9]{1,5}' | tr -d ':' | tail -1)
228
+ # 2) A number anchored to the literal word "port": "port 8080", "port=3000",
229
+ # "port: 5000". This runs BEFORE the bare host:port scan so a clock-style
230
+ # timestamp on the same line (e.g. "12:30:45 ... port 8080") cannot win.
231
+ if [ -z "$candidate" ]; then
232
+ candidate=$(printf '%s\n' "$clean" \
233
+ | grep -ioE 'port[ =:]+[0-9]{1,5}' \
234
+ | grep -oE '[0-9]{1,5}' | tail -1)
235
+ fi
236
+ # 3) Keyword listen lines with a real host token before the colon:
237
+ # "localhost:5173", "0.0.0.0:8080", "127.0.0.1:3000". Requiring a letter
238
+ # or a dot immediately left of the colon excludes "HH:MM" timestamps,
239
+ # which have a digit there.
240
+ if [ -z "$candidate" ]; then
241
+ candidate=$(printf '%s\n' "$clean" \
242
+ | grep -iE 'listen|running on|ready|started|serving|server' \
243
+ | grep -oiE '[a-z.][a-z0-9.\-]*:[0-9]{1,5}' \
244
+ | grep -oE ':[0-9]{1,5}' | tr -d ':' | tail -1)
245
+ fi
246
+
247
+ [ -n "$candidate" ] || return 0
248
+ # Validate range 1-65535.
249
+ if [ "$candidate" -ge 1 ] 2>/dev/null && [ "$candidate" -le 65535 ] 2>/dev/null; then
250
+ printf '%s\n' "$candidate"
251
+ fi
252
+ }
253
+
139
254
  # Rotate app.log if it exceeds max lines
140
255
  _rotate_app_log() {
141
256
  local log_file="$_APP_RUNNER_DIR/app.log"
@@ -606,6 +721,21 @@ app_runner_start() {
606
721
  log_step "App Runner: starting application ($_APP_RUNNER_METHOD on port $_APP_RUNNER_PORT)..."
607
722
  _rotate_app_log
608
723
 
724
+ # Fix #1 (finding #597): pass Loki's chosen port to the app via the env so the
725
+ # app honors it instead of binding its own default (e.g. a Node app reading
726
+ # `process.env.PORT || 4000` would otherwise bind 4000 while Loki recorded the
727
+ # guessed 3000, leaving the dashboard Live Preview pointed at a dead port).
728
+ # We export PORT plus the common ecosystem aliases. An app that ignores these
729
+ # vars is unaffected; an ignored env var is harmless by definition. We do NOT
730
+ # set HOST/BIND -- changing the bind address can break apps. For docker (which
731
+ # gets its port via published-port mapping, not the child env) this is a no-op
732
+ # at the binary boundary, so we only export for the direct-exec path.
733
+ local _port_env_prefix=""
734
+ if [ "$_APP_RUNNER_IS_DOCKER" != true ] && \
735
+ [ -n "$_APP_RUNNER_PORT" ] && [ "$_APP_RUNNER_PORT" -gt 0 ] 2>/dev/null; then
736
+ _port_env_prefix="export PORT=$_APP_RUNNER_PORT HTTP_PORT=$_APP_RUNNER_PORT SERVER_PORT=$_APP_RUNNER_PORT APP_PORT=$_APP_RUNNER_PORT; "
737
+ fi
738
+
609
739
  # Start the process in a new process group
610
740
  if command -v setsid >/dev/null 2>&1; then
611
741
  _APP_RUNNER_HAS_SETSID=true
@@ -615,7 +745,7 @@ app_runner_start() {
615
745
  # Note: $_APP_RUNNER_METHOD has passed _validate_app_command (whitelist).
616
746
  # The `--` after `bash -lc` prevents flag injection if the assembled
617
747
  # script string ever begins with a `-`.
618
- (cd "$dir" && setsid bash -lc -- 'echo $$ > "'"$_pgid_file"'"; exec '"$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
748
+ (cd "$dir" && setsid bash -lc -- "$_port_env_prefix"'echo $$ > "'"$_pgid_file"'"; exec '"$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
619
749
  local _subshell_pid=$!
620
750
  # Wait briefly for the pgid file to appear, then read the real PGID
621
751
  local _pgid_wait=0
@@ -633,7 +763,7 @@ app_runner_start() {
633
763
  _APP_RUNNER_HAS_SETSID=false
634
764
  # Note: $_APP_RUNNER_METHOD has passed _validate_app_command (whitelist).
635
765
  # The `--` after `bash -lc` prevents flag injection.
636
- (cd "$dir" && bash -lc -- "$_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
766
+ (cd "$dir" && bash -lc -- "${_port_env_prefix}exec $_APP_RUNNER_METHOD" >> "$_APP_RUNNER_DIR/app.log" 2>&1) &
637
767
  _APP_RUNNER_PID=$!
638
768
  fi
639
769
  # Register with central PID registry if available
@@ -675,8 +805,13 @@ app_runner_start() {
675
805
  return 1
676
806
  fi
677
807
  elif kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
808
+ # Reconcile recorded port with the port the app actually bound (finding
809
+ # #597), so state.json / detection.json / the preview URL point at the
810
+ # live port even when the app ignored PORT. Mutates globals before the
811
+ # state write below. Bounded; no-op when the app honored the chosen port.
812
+ _app_runner_reconcile_port
678
813
  _write_app_state "running"
679
- log_info "App Runner: application started (PID: $_APP_RUNNER_PID)"
814
+ log_info "App Runner: application started (PID: $_APP_RUNNER_PID) on port $_APP_RUNNER_PORT"
680
815
  return 0
681
816
  else
682
817
  log_error "App Runner: application failed to start"
@@ -1775,7 +1775,15 @@ ISSUES: CRITICAL:description (optional, one per line per issue)"
1775
1775
  if type loki_review_guard_enabled >/dev/null 2>&1 && loki_review_guard_enabled; then
1776
1776
  _cm_argv+=("--disallowedTools" "$(loki_review_guard_denylist)")
1777
1777
  fi
1778
- verdict=$(echo "$prompt" | claude "${_cm_argv[@]}" -p 2>/dev/null | tail -20)
1778
+ # caveman HARD-SUPPRESS (parsed output): this council vote is
1779
+ # parsed for "VOTE: APPROVE|REJECT|CANNOT_VALIDATE". A globally-
1780
+ # active caveman would compress/reword that line and silently flip
1781
+ # the vote to the default REJECT, corrupting completion detection.
1782
+ # Disable caveman UNCONDITIONALLY with CAVEMAN_DEFAULT_MODE=off.
1783
+ # Set inline (not via a helper) so the carve-out holds even when
1784
+ # this file is sourced standalone and the helpers are out of scope.
1785
+ # Inlined on `claude` only (does not cross the pipe). No-op absent.
1786
+ verdict=$(echo "$prompt" | env CAVEMAN_DEFAULT_MODE=off claude "${_cm_argv[@]}" -p 2>/dev/null | tail -20)
1779
1787
  fi
1780
1788
  ;;
1781
1789
  codex)
@@ -1870,7 +1878,11 @@ REASON: your reasoning"
1870
1878
  if type loki_review_guard_enabled >/dev/null 2>&1 && loki_review_guard_enabled; then
1871
1879
  _co_argv+=("--disallowedTools" "$(loki_review_guard_denylist)")
1872
1880
  fi
1873
- verdict=$(echo "$prompt" | claude "${_co_argv[@]}" -p 2>/dev/null | tail -20)
1881
+ # caveman HARD-SUPPRESS (parsed output): the devil's-advocate
1882
+ # (contrarian) vote is parsed for "VOTE:". Disable caveman
1883
+ # unconditionally so compression cannot flip the contrarian vote.
1884
+ # Inlined on `claude` only (does not cross the pipe). No-op absent.
1885
+ verdict=$(echo "$prompt" | env CAVEMAN_DEFAULT_MODE=off claude "${_co_argv[@]}" -p 2>/dev/null | tail -20)
1874
1886
  fi
1875
1887
  ;;
1876
1888
  codex)
@@ -276,7 +276,16 @@ Respond ONLY with a valid JSON object. No markdown fencing."
276
276
  if type loki_review_guard_enabled >/dev/null 2>&1 && loki_review_guard_enabled; then
277
277
  _c2_argv+=("--disallowedTools" "$(loki_review_guard_denylist)")
278
278
  fi
279
- result=$(echo "$full_prompt" | claude "${_c2_argv[@]}" -p 2>/dev/null || echo '{"verdict":"REJECT","reasoning":"review execution failed","issues":[]}')
279
+ # caveman HARD-SUPPRESS (parsed output, v7.41.0): this reviewer
280
+ # verdict is captured and parsed for the JSON "verdict" field. A
281
+ # globally-active caveman would compress/reword that JSON and
282
+ # silently flip the verdict to the REJECT fallback. The tree-wide
283
+ # default-off export in claude-flags.sh already covers this (the
284
+ # whole subprocess tree inherits CAVEMAN_DEFAULT_MODE=off); the
285
+ # inline prefix here is belt-and-suspenders so the carve-out is
286
+ # self-documenting and robust to sourcing order. No-op when caveman
287
+ # is absent.
288
+ result=$(echo "$full_prompt" | CAVEMAN_DEFAULT_MODE=off claude "${_c2_argv[@]}" -p 2>/dev/null || echo '{"verdict":"REJECT","reasoning":"review execution failed","issues":[]}')
280
289
  else
281
290
  result='{"verdict":"REJECT","reasoning":"reviewer CLI unavailable","issues":[]}'
282
291
  fi
package/autonomy/grill.sh CHANGED
@@ -204,8 +204,16 @@ grill_invoke_provider() {
204
204
  if type loki_review_guard_enabled >/dev/null 2>&1 && loki_review_guard_enabled; then
205
205
  _gr_argv+=("--disallowedTools" "$(loki_review_guard_denylist)")
206
206
  fi
207
+ # caveman HARD-SUPPRESS (parsed output, v7.41.0): the grill output is
208
+ # parsed downstream by loki-grill (and written to report.md as the
209
+ # hardest spec questions). Treat it as parsed: a globally-active
210
+ # caveman would compress/reword the questions. The tree-wide default-off
211
+ # export in claude-flags.sh (sourced at grill.sh:45) already covers
212
+ # this; the inline `env` prefix is belt-and-suspenders. `env` is used
213
+ # (not a bare VAR=val prefix) because the call goes through
214
+ # _grill_with_timeout, where the first token is exec'd as the command.
207
215
  out="$(printf '%s' "$prompt" \
208
- | _grill_with_timeout "${LOKI_GRILL_TIMEOUT:-180}" claude "${_gr_argv[@]}" -p - 2>/dev/null)"
216
+ | _grill_with_timeout "${LOKI_GRILL_TIMEOUT:-180}" env CAVEMAN_DEFAULT_MODE=off claude "${_gr_argv[@]}" -p - 2>/dev/null)"
209
217
  if [ -z "$out" ]; then
210
218
  _grill_err "provider returned no output (timeout or invocation error)"
211
219
  return $GRILL_EXIT_ERROR
@@ -482,6 +482,327 @@ loki_workflows_enabled() {
482
482
  [ "${LOKI_USE_CLAUDE_WORKFLOWS:-0}" = "1" ]
483
483
  }
484
484
 
485
+ # ---------- v7.x caveman output-token compressor gates ----------
486
+ # caveman (https://github.com/JuliusBrussee/caveman, MIT, vendor-less pin) is a
487
+ # Claude Code SKILL + SessionStart hook that instructs the model to compress its
488
+ # OUTPUT TOKENS only (prose style: lite / full / ultra / wenyan), keeping all
489
+ # technical substance. It wraps NO API, needs NO key, has NO network of its own.
490
+ # Once installed it activates GLOBALLY in Claude Code via a SessionStart hook
491
+ # that reads getDefaultMode() (env CAVEMAN_DEFAULT_MODE > repo .caveman config >
492
+ # user config > "full") and, unless the mode is "off", injects its ruleset.
493
+ #
494
+ # THE MOAT RISK (central, why this is wired the way it is): Loki's trust gates
495
+ # parse EXACT model prose -- council "VOTE: APPROVE|REJECT|CANNOT_VALIDATE", code
496
+ # review "^VERDICT:", the legacy completion-promise grep, the evidence-gate
497
+ # sentinels. A globally-active caveman would compress those subcall outputs and
498
+ # silently flip a verdict to the default (REJECT / not-complete), corrupting the
499
+ # loop. This is the same failure class as the --bare OAuth footgun documented at
500
+ # claude-flags.sh:152-161.
501
+ #
502
+ # THE DESIGN (off by construction, not by convention):
503
+ # - ACTIVATE compression only on FREE-FORM generation (main RARV dev loop +
504
+ # read-only codebase-analysis): inline `CAVEMAN_DEFAULT_MODE=<level> claude`.
505
+ # - HARD-SUPPRESS on EVERY parsed-output subcall (council vote, ^VERDICT:
506
+ # review, adversarial probe, conflict-merge, USAGE regen): inline
507
+ # `CAVEMAN_DEFAULT_MODE=off claude`. The activate hook then deletes its flag
508
+ # and emits nothing (verified: `CAVEMAN_DEFAULT_MODE=off node
509
+ # caveman-activate.js` prints "OK" with no ruleset).
510
+ #
511
+ # Suppression is UNCONDITIONAL and UNGATED (see loki_caveman_suppress_env): it is
512
+ # a harmless no-op env value when caveman is absent and the essential carve-out
513
+ # when caveman is globally present (surface b, or a user's own install) even with
514
+ # LOKI_CAVEMAN=0. NEVER gate suppression on supported/enabled -- that would leave
515
+ # the trust gates unprotected exactly when a user has caveman on but Loki off.
516
+ #
517
+ # Disclosure (honest, no fabricated figures): caveman compresses OUTPUT tokens
518
+ # only, not input/thinking; savings are real but bounded. There is no price API,
519
+ # so we disclose the savings CLASS, never a dollar amount (same posture as the
520
+ # ultrareview/workflows gates).
521
+
522
+ # Version pin (vendor-less). Upgrade by bumping this. The upstream installer pins
523
+ # its hook downloads to PINNED_REF = CAVEMAN_REF || 'v1.9.0' (a git tag), and the
524
+ # curl|bash path delegates to `npx -y github:JuliusBrussee/caveman#<ref>`. We
525
+ # default to 1.9.0 and derive the `v`-prefixed tag in the bootstrap helper.
526
+ LOKI_CAVEMAN_VERSION="${LOKI_CAVEMAN_VERSION:-1.9.0}"
527
+
528
+ # The compression level for free-form activation. Maps directly to caveman's
529
+ # CAVEMAN_DEFAULT_MODE values: lite | full | ultra | wenyan | wenyan-lite |
530
+ # wenyan-full | wenyan-ultra. Never "off" here -- "off" is the suppression value,
531
+ # not an activation level.
532
+ #
533
+ # v7.x #593 -- INTELLIGENT AUTO-SELECTION (no new user knob): when the user does
534
+ # NOT set LOKI_CAVEMAN_LEVEL explicitly, the level is INFERRED per-invocation from
535
+ # the run's existing RARV-tier signal (see _loki_caveman_infer_level). When the
536
+ # user DOES set it, that value overrides the inference entirely (opt-out escape
537
+ # hatch). Capture set-ness BEFORE the ":-full" default clobbers it -- once the
538
+ # default fills the var, "user set full" and "defaulted to full" are
539
+ # indistinguishable, so the inference would silently never fire. ${var+set} is
540
+ # non-empty only when the var was genuinely set (even to empty). The "full"
541
+ # default is kept so the public var still reads "full" for external readers and
542
+ # so a child re-source re-derives USERSET correctly (unexported default).
543
+ if [ -z "${LOKI_CAVEMAN_LEVEL_USERSET+x}" ]; then
544
+ LOKI_CAVEMAN_LEVEL_USERSET="${LOKI_CAVEMAN_LEVEL+set}"
545
+ fi
546
+ LOKI_CAVEMAN_LEVEL="${LOKI_CAVEMAN_LEVEL:-full}"
547
+
548
+ # ---------- DEFAULT-SUPPRESS: off by construction, tree-wide ----------
549
+ # THE MOAT GUARANTEE (v7.41.0 council fix): instead of hand-enumerating every
550
+ # parsed-output trust-gate subcall and remembering to prefix each with
551
+ # CAVEMAN_DEFAULT_MODE=off (a missed site silently corrupts a verdict -- caveman
552
+ # exits 0 with mangled prose and the `|| REJECT` fallback never fires), we flip
553
+ # the ENTIRE process tree to suppressed at the one module every tree sources.
554
+ #
555
+ # claude-flags.sh is sourced by EVERY tree that can spawn a parsed claude
556
+ # subcall: the run.sh orchestrator (via providers/claude.sh), grill.sh (standalone
557
+ # `loki grill`), lib/voter-agents.sh (Phase C agent-dispatch voters), and the
558
+ # loki standalone review/workflows commands (on-demand). council-v2.sh carries no
559
+ # source of its own but only ever runs inside completion-council.sh, which is in
560
+ # the run.sh tree, so it inherits this export too. Exporting off HERE makes the
561
+ # whole spawned subprocess tree inherit suppression -- caveman's SessionStart
562
+ # hook reads process.env CAVEMAN_DEFAULT_MODE -- closing council-v2.sh,
563
+ # voter-agents.sh, grill.sh, every existing parsed subcall, and any FUTURE one by
564
+ # construction. ACTIVATION on the handful of free-form generation sites overrides
565
+ # this per-invocation (CAVEMAN_DEFAULT_MODE=<level> claude ...).
566
+ #
567
+ # Capture the user's pre-existing global CAVEMAN_DEFAULT_MODE BEFORE we clobber
568
+ # it, so the activation path can respect (never RAISE) a user's lower level (see
569
+ # loki_caveman_activate_env). Guarded on UNSET (not empty): a child process that
570
+ # inherits our exported LOKI_CAVEMAN_USER_MODE="" (user had no global mode) and
571
+ # re-sources this file must NOT recapture the now-exported CAVEMAN_DEFAULT_MODE=off
572
+ # as the user mode. ${var+x} is empty only when var is genuinely unset, so the
573
+ # capture runs exactly once across the whole process tree, never recapturing "off".
574
+ if [ -z "${LOKI_CAVEMAN_USER_MODE+x}" ]; then
575
+ LOKI_CAVEMAN_USER_MODE="${CAVEMAN_DEFAULT_MODE:-}"
576
+ fi
577
+ export LOKI_CAVEMAN_USER_MODE
578
+ export CAVEMAN_DEFAULT_MODE=off
579
+
580
+ # ---------- v7.x #593 intelligent compression-level inference ----------
581
+ # Infer the caveman compression level from the run's existing RARV-tier signal,
582
+ # so the level is DECIDED by inspecting the work rather than asked of the user.
583
+ # No new user input is introduced: the tier already drives effort/model selection
584
+ # (loki_effort_for_tier, get_rarv_tier). On the bash route the tier is read from
585
+ # LOKI_CURRENT_TIER (exported by run_autonomous each iteration); the TS mirror
586
+ # (cavemanActivateEnv) receives the same tier vocabulary (planning|development|
587
+ # fast) via call.tier, so both routes infer identically from the same signal.
588
+ #
589
+ # INFERENCE RULE (deterministic, conservative-for-accuracy):
590
+ # planning (Reason phase -- architecture / design / nuanced reasoning) -> lite
591
+ # development (Act / Reflect -- implementation, the prior default) -> full
592
+ # fast (Verify phase -- testing / validation, more routine) -> full
593
+ # unknown / empty tier -> full
594
+ # The auto ceiling is "full": inference NEVER selects ultra. ultra is reachable
595
+ # only via an explicit LOKI_CAVEMAN_LEVEL override (the opt-out escape hatch), so
596
+ # the autonomous path can never compress hard enough to lose technical nuance.
597
+ # "lite" on planning protects the highest-nuance output (architecture/design);
598
+ # everything else stays at the established "full" default. When the tier is
599
+ # unknown we pick the SAFER (established) "full", never something more aggressive.
600
+ _loki_caveman_infer_level() {
601
+ local tier="${1:-${LOKI_CURRENT_TIER:-}}"
602
+ case "$tier" in
603
+ planning) printf '%s' "lite" ;;
604
+ *) printf '%s' "full" ;;
605
+ esac
606
+ }
607
+
608
+ # Rank a caveman mode by compression aggressiveness for the no-raise comparison.
609
+ # Higher number = more aggressive (drops more nuance). "off" is the floor; unknown
610
+ # or empty modes rank as -1 (treated as "no opinion", so they never win a min()).
611
+ # The wenyan-* variants mirror their plain counterparts' aggressiveness.
612
+ _loki_caveman_level_rank() {
613
+ case "${1:-}" in
614
+ off) printf '0' ;;
615
+ lite|wenyan-lite) printf '1' ;;
616
+ full|wenyan|wenyan-full) printf '2' ;;
617
+ ultra|wenyan-ultra) printf '3' ;;
618
+ *) printf '%s' '-1' ;;
619
+ esac
620
+ }
621
+
622
+ # Caveman config dir resolution mirrors caveman-config.js getConfigDir(): honors
623
+ # CLAUDE_CONFIG_DIR for the flag file location used to detect an existing install.
624
+ _loki_caveman_claude_dir() {
625
+ printf '%s' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
626
+ }
627
+
628
+ # True (0) when caveman appears installed: its SessionStart hook file exists in
629
+ # the resolved Claude config dir. Best-effort, read-only. We check the hook the
630
+ # upstream installer writes to ~/.claude/hooks/caveman-activate.js (standalone)
631
+ # OR a plugin install marker. Either presence means activation will fire.
632
+ _loki_caveman_installed() {
633
+ local dir
634
+ dir="$(_loki_caveman_claude_dir)"
635
+ [ -f "$dir/hooks/caveman-activate.js" ] && return 0
636
+ # Plugin install: the activate hook lives under a plugin root; the flag file
637
+ # path is stable. Treat a prior-session flag file as a weaker install signal.
638
+ [ -f "$dir/.caveman-active" ] && return 0
639
+ return 1
640
+ }
641
+
642
+ # Capability gate: can caveman compression be USED on this run? Provider is
643
+ # Claude AND the claude CLI is present AND caveman is installed-or-bootstrappable
644
+ # AND it is not disabled by the LOKI_CAVEMAN knob. Returns 0 when usable, 1
645
+ # otherwise (callers emit an honest message + degrade to an uncompressed run).
646
+ # Mirrors loki_workflows_supported's shape (provider + CLI + not-disabled).
647
+ loki_caveman_supported() {
648
+ # Provider must be Claude (Tier 1). caveman is Claude-Code-only.
649
+ [ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
650
+ command -v claude >/dev/null 2>&1 || return 1
651
+ # Opt-out knob also suppresses the capability (no activation when off).
652
+ [ "${LOKI_CAVEMAN:-1}" = "0" ] && return 1
653
+ # Installed now, OR bootstrappable (node + npx present so the pin can install
654
+ # on demand). Either way activation can take effect this run or the next.
655
+ if _loki_caveman_installed; then
656
+ return 0
657
+ fi
658
+ command -v node >/dev/null 2>&1 && command -v npx >/dev/null 2>&1 && return 0
659
+ return 1
660
+ }
661
+
662
+ # Activation knob: is caveman compression ENABLED for free-form subcalls?
663
+ # DEFAULT ON (LOKI_CAVEMAN unset or 1). Opt out with LOKI_CAVEMAN=0.
664
+ #
665
+ # CROSS-COUPLING GUARD (moat safety): when LOKI_LEGACY_COMPLETION_MATCH=true the
666
+ # runner detects completion by grepping the MAIN-loop prose for the completion
667
+ # promise (run.sh:9641). Compressing the main loop would mangle that prose and
668
+ # break the legacy detector, so caveman activation is DISABLED whenever the
669
+ # legacy prose-match path is in use. The default completion path (the
670
+ # loki_complete_task MCP tool / COMPLETION_REQUESTED signal file) is immune to
671
+ # compression, so the default config keeps caveman on.
672
+ loki_caveman_enabled() {
673
+ [ "${LOKI_CAVEMAN:-1}" = "0" ] && return 1
674
+ [ "${LOKI_LEGACY_COMPLETION_MATCH:-false}" = "true" ] && return 1
675
+ return 0
676
+ }
677
+
678
+ # The activation env VALUE for a free-form subcall: the configured level, or
679
+ # empty when activation is not warranted (caveman unsupported or disabled). The
680
+ # caller inlines it as a per-invocation env prefix (NEVER `export` -- a persisted
681
+ # export would bleed into later parsed subcalls and defeat the carve-out):
682
+ # _cm_lvl="$(loki_caveman_activate_env)"
683
+ # if [ -n "$_cm_lvl" ]; then
684
+ # CAVEMAN_DEFAULT_MODE="$_cm_lvl" claude ... # free-form only
685
+ # else
686
+ # claude ...
687
+ # fi
688
+ #
689
+ # NO-RAISE (v7.41.0 R2 finding 4): the level returned is the configured Loki
690
+ # level, EXCEPT we never silently RAISE a user who set a LOWER global caveman
691
+ # level. If the user globally chose "lite" (less aggressive, preserves more
692
+ # nuance) we honor "lite" rather than forcing "full" on their free-form output.
693
+ # We only ever lower toward the user's preference, never above it; the activation
694
+ # level itself is the conservative-for-accuracy ceiling. The user's global mode is
695
+ # captured at source time into LOKI_CAVEMAN_USER_MODE before the default-off
696
+ # export clobbers CAVEMAN_DEFAULT_MODE. Unknown / empty user modes (rank -1) are
697
+ # ignored so a malformed value can never accidentally suppress activation.
698
+ loki_caveman_activate_env() {
699
+ loki_caveman_supported || return 0
700
+ loki_caveman_enabled || return 0
701
+ # #593: the level is the EXPLICIT user value when LOKI_CAVEMAN_LEVEL was set
702
+ # (override / opt-out escape hatch), else the INFERRED level from the RARV
703
+ # tier. The no-raise guard below then runs unchanged on this base, so an
704
+ # explicit level is still lowered toward a user's lower global mode exactly
705
+ # as before -- "override" means override the inference, not the no-raise.
706
+ local level
707
+ if [ -n "${LOKI_CAVEMAN_LEVEL_USERSET:-}" ]; then
708
+ level="${LOKI_CAVEMAN_LEVEL:-full}"
709
+ else
710
+ level="$(_loki_caveman_infer_level)"
711
+ fi
712
+ # Respect (never exceed) a user's explicitly-lower global level. A user who
713
+ # globally set CAVEMAN_DEFAULT_MODE=off opted OUT of compression entirely;
714
+ # honor that by activating nothing (empty -> bare claude invocation).
715
+ local user_mode="${LOKI_CAVEMAN_USER_MODE:-}"
716
+ if [ "$user_mode" = "off" ]; then
717
+ return 0
718
+ fi
719
+ if [ -n "$user_mode" ]; then
720
+ local user_rank level_rank
721
+ user_rank="$(_loki_caveman_level_rank "$user_mode")"
722
+ level_rank="$(_loki_caveman_level_rank "$level")"
723
+ # Only defer to the user when their mode is a recognized, lower level.
724
+ if [ "$user_rank" -ge 0 ] && [ "$level_rank" -ge 0 ] && [ "$user_rank" -lt "$level_rank" ]; then
725
+ level="$user_mode"
726
+ fi
727
+ fi
728
+ printf '%s' "$level"
729
+ }
730
+
731
+ # The suppression env VALUE for a parsed-output subcall: ALWAYS "off",
732
+ # UNCONDITIONALLY. Not gated on supported/enabled (see the design note above):
733
+ # it must hard-disable caveman on a trust-gate subcall even when a user has
734
+ # caveman globally on but LOKI_CAVEMAN=0, and it is a harmless no-op env value
735
+ # when caveman is absent. Every parsed-output call site uses this ONE helper so
736
+ # the carve-out is uniform:
737
+ # CAVEMAN_DEFAULT_MODE="$(loki_caveman_suppress_env)" claude ...
738
+ loki_caveman_suppress_env() {
739
+ printf '%s' "off"
740
+ }
741
+
742
+ # Idempotent on-demand bootstrap of caveman at the pinned version. Best-effort:
743
+ # installs once per machine, caches a marker under .loki/ so repeat runs are a
744
+ # no-op, degrades cleanly (run proceeds UNCOMPRESSED) with an honest stderr line
745
+ # if anything is missing or the upstream installer is unreachable. NEVER blocks
746
+ # or fails the run. Returns 0 if caveman is (now) installed, 1 on clean degrade.
747
+ #
748
+ # Opt out with LOKI_CAVEMAN_AUTO_BOOTSTRAP=0. Only attempts when provider==claude
749
+ # and the LOKI_CAVEMAN knob is on.
750
+ #
751
+ # GLOBAL SIDE EFFECT (disclosed): caveman installs GLOBALLY -- the upstream
752
+ # installer adds a SessionStart hook to ~/.claude/settings.json (or
753
+ # $CLAUDE_CONFIG_DIR) that affects EVERY Claude Code session on this machine, not
754
+ # only Loki runs. This is caveman's only install mode. The bootstrap therefore
755
+ # runs the upstream installer exactly as a user's own `curl|bash` would; we do
756
+ # not author or vendor any caveman file. The one-time stderr line below names
757
+ # this so the operator is never surprised.
758
+ #
759
+ # HARDENING: the npx call is forced non-interactive (--non-interactive, plus
760
+ # </dev/null so no stdin read can ever block) and time-bounded (timeout when
761
+ # available) so a stalled network or an unexpected prompt can never hang a user's
762
+ # first `loki start`. caveman's installer is already auto-non-interactive without
763
+ # a TTY, but we belt-and-suspenders it.
764
+ loki_caveman_bootstrap() {
765
+ [ "${LOKI_CAVEMAN:-1}" = "0" ] && return 1
766
+ [ "${LOKI_CAVEMAN_AUTO_BOOTSTRAP:-1}" = "0" ] && return 1
767
+ [ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
768
+ # Already installed -> nothing to do.
769
+ if _loki_caveman_installed; then
770
+ return 0
771
+ fi
772
+ local ver="${LOKI_CAVEMAN_VERSION:-1.9.0}"
773
+ local marker_dir=".loki/state"
774
+ local marker="$marker_dir/caveman-bootstrap-${ver}.done"
775
+ # Cached attempt marker: do not re-attempt a failed install over and over
776
+ # within the same project tree (idempotent one-shot per pinned version).
777
+ if [ -f "$marker" ]; then
778
+ _loki_caveman_installed && return 0 || return 1
779
+ fi
780
+ if ! command -v node >/dev/null 2>&1 || ! command -v npx >/dev/null 2>&1; then
781
+ printf '%s\n' "[caveman] node>=18 + npx required to bootstrap; skipping (run proceeds uncompressed). Install Node or set LOKI_CAVEMAN=0 to silence." >&2
782
+ mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
783
+ return 1
784
+ fi
785
+ printf '%s\n' "[caveman] bootstrapping output-token compressor v${ver} (one-time, pinned). NOTE: caveman installs GLOBALLY (a Claude Code SessionStart hook in ~/.claude affecting every Claude Code session). Loki applies it only to free-form generation, NEVER to trust-gate subcalls. Opt out: LOKI_CAVEMAN=0." >&2
786
+ # Pin via the git tag (v-prefixed) on the npx ref AND CAVEMAN_REF so the
787
+ # downloaded hooks match the pinned release. Default install (no --all) wires
788
+ # the Claude Code hook for the detected `claude` CLI. A timeout backstops a
789
+ # network stall; </dev/null guarantees no interactive stdin read blocks.
790
+ local _cm_runner="npx"
791
+ if command -v timeout >/dev/null 2>&1; then
792
+ _cm_runner="timeout 120 npx"
793
+ fi
794
+ if CAVEMAN_REF="v${ver}" $_cm_runner -y "github:JuliusBrussee/caveman#v${ver}" -- --non-interactive >/dev/null 2>&1 </dev/null; then
795
+ mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
796
+ if _loki_caveman_installed; then
797
+ printf '%s\n' "[caveman] installed v${ver}." >&2
798
+ return 0
799
+ fi
800
+ fi
801
+ printf '%s\n' "[caveman] bootstrap unavailable (upstream unreachable, timed out, or install failed); run proceeds uncompressed." >&2
802
+ mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
803
+ return 1
804
+ }
805
+
485
806
  # ---------------------------------------------------------------------------
486
807
  # Session-continuity Phase 2 (GitHub #165) -- LOKI_RESUME_SESSION recovery resume
487
808
  #
@@ -250,7 +250,13 @@ loki_council_dispatch_agents() {
250
250
  local rc=0
251
251
  local stderr_log="$COUNCIL_STATE_DIR/votes/dispatch-stderr-${iteration}.log"
252
252
  mkdir -p "$(dirname "$stderr_log")" 2>/dev/null || true
253
- response=$(claude --dangerously-skip-permissions \
253
+ # caveman HARD-SUPPRESS (parsed output, v7.41.0): the response is parsed for
254
+ # findings[].vote against the JSON Schema. A globally-active caveman would
255
+ # compress/reword it and break the schema match or flip a vote. The tree-wide
256
+ # default-off export in claude-flags.sh (sourced above) already covers this;
257
+ # the inline prefix is belt-and-suspenders, self-documenting, and a no-op when
258
+ # caveman is absent.
259
+ response=$(CAVEMAN_DEFAULT_MODE=off claude --dangerously-skip-permissions \
254
260
  -p "$prompt" \
255
261
  --agents "$agents_json" \
256
262
  --json-schema "$schema_path" 2>"$stderr_log") || rc=$?