loki-mode 7.96.0 → 7.98.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/README.md CHANGED
@@ -197,7 +197,7 @@ loki quick "build a landing page with a signup form"
197
197
  | **Bun (recommended)** | `bun install -g loki-mode` | Fastest startup for CLI commands. |
198
198
  | **Homebrew** | `brew tap asklokesh/tap && brew install loki-mode` | Auto-installs Bun as a dep |
199
199
  | **Docker (easiest)** | `loki docker start prd.md` | Host wrapper: runs loki in the published image with zero config. Bind-mounts the current folder so `.loki` state, resume, and continuity work exactly like local. Auto-detects auth (`ANTHROPIC_API_KEY`, else your host Claude Code login). Needs loki + Docker on the host. See DOCKER_README.md |
200
- | **Docker (raw)** | `docker pull asklokesh/loki-mode:7.58.1 && docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" asklokesh/loki-mode:7.58.1 start prd.md` | Bun + Claude CLI pre-installed; needs an API key, or use docker compose with a .env file, see DOCKER_README.md |
200
+ | **Docker (raw)** | `docker pull asklokesh/loki-mode:latest && docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" asklokesh/loki-mode:latest start prd.md` | Bun + Claude CLI pre-installed; needs an API key, or use docker compose with a .env file, see DOCKER_README.md |
201
201
  | **npm (compat)** | `npm install -g loki-mode` | Works without Bun (bash fallback). Migrate any time with `loki self-update --to bun`. |
202
202
 
203
203
  **Upgrading:**
@@ -257,7 +257,7 @@ The next major release sunsets the Bash runtime entirely. There is no firm calen
257
257
  | Method | Command |
258
258
  |--------|---------|
259
259
  | **Homebrew** | `brew tap asklokesh/tap && brew install loki-mode` |
260
- | **Docker** | `docker pull asklokesh/loki-mode:7.58.1` |
260
+ | **Docker** | `docker pull asklokesh/loki-mode:latest` |
261
261
  | **Inside Claude Code** | `claude --dangerously-skip-permissions` then type "Loki Mode" |
262
262
  | **Git clone** | `git clone https://github.com/asklokesh/loki-mode.git` |
263
263
 
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.96.0
6
+ # Loki Mode v7.98.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.96.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.98.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.96.0
1
+ 7.98.0
@@ -44,7 +44,37 @@
44
44
  # Council configuration
45
45
  COUNCIL_ENABLED=${LOKI_COUNCIL_ENABLED:-true}
46
46
  COUNCIL_SIZE=${LOKI_COUNCIL_SIZE:-3}
47
+ # Guard COUNCIL_SIZE: a non-positive/non-numeric size would make the 2/3 floor 0
48
+ # and let an empty council "approve" by default (fake-green). Force a sane floor.
49
+ case "$COUNCIL_SIZE" in
50
+ ''|*[!0-9]*) COUNCIL_SIZE=3 ;;
51
+ esac
52
+ [ "$COUNCIL_SIZE" -lt 1 ] 2>/dev/null && COUNCIL_SIZE=3
47
53
  COUNCIL_THRESHOLD=${LOKI_COUNCIL_THRESHOLD:-2}
54
+
55
+ # Effective completion threshold: the operator's COUNCIL_THRESHOLD may only ever
56
+ # TIGHTEN the gate, never weaken it below the 2/3-majority safety floor. Before
57
+ # this, the operator's COUNCIL_THRESHOLD was silently ignored by the vote (a
58
+ # hardcoded 2/3 formula was used), so an operator who set a stricter threshold
59
+ # got a no-op while the logs claimed it was active. We honor it as a floor-raise
60
+ # only: effective = max(ceil(2/3*size), operator_threshold), clamped to size.
61
+ _council_effective_threshold() {
62
+ # Optional $1 = the size to compute against (e.g. the actual number of voters
63
+ # present in this round); defaults to COUNCIL_SIZE. The 2/3 floor + the
64
+ # operator clamp are both relative to this size, so the helper works for both
65
+ # the configured council size and a per-round member count.
66
+ local _size="${1:-$COUNCIL_SIZE}"
67
+ case "$_size" in ''|*[!0-9]*) _size="$COUNCIL_SIZE" ;; esac
68
+ [ "$_size" -lt 1 ] 2>/dev/null && _size=1
69
+ local _floor=$(( (_size * 2 + 2) / 3 ))
70
+ local _op="${COUNCIL_THRESHOLD:-0}"
71
+ case "$_op" in ''|*[!0-9]*) _op=0 ;; esac
72
+ local _eff="$_floor"
73
+ [ "$_op" -gt "$_eff" ] 2>/dev/null && _eff="$_op"
74
+ [ "$_eff" -gt "$_size" ] 2>/dev/null && _eff="$_size"
75
+ [ "$_eff" -lt 1 ] 2>/dev/null && _eff=1
76
+ printf '%s' "$_eff"
77
+ }
48
78
  COUNCIL_CHECK_INTERVAL=${LOKI_COUNCIL_CHECK_INTERVAL:-5}
49
79
  # Guard against invalid interval (must be positive integer)
50
80
  if ! [[ "$COUNCIL_CHECK_INTERVAL" =~ ^[1-9][0-9]*$ ]]; then
@@ -608,8 +638,11 @@ council_vote() {
608
638
  log_header "COMPLETION COUNCIL - Iteration $ITERATION_COUNT"
609
639
  log_info "Convening ${COUNCIL_SIZE}-member council..."
610
640
 
611
- # Compute threshold using ceiling(2/3) formula, consistent with council_aggregate_votes
612
- local effective_threshold=$(( (COUNCIL_SIZE * 2 + 2) / 3 ))
641
+ # Effective threshold honors the operator's COUNCIL_THRESHOLD as a floor-raise
642
+ # over the 2/3-majority safety floor (tighten-only); see
643
+ # _council_effective_threshold.
644
+ local effective_threshold
645
+ effective_threshold=$(_council_effective_threshold)
613
646
 
614
647
  # Gather evidence for council members
615
648
  local evidence_file="$vote_dir/evidence.md"
@@ -2604,7 +2637,13 @@ print(json.dumps(out))
2604
2637
  " 2>/dev/null || echo "[]")
2605
2638
 
2606
2639
  # Calculate threshold: 2/3 majority
2607
- local threshold=$(( (total_members * 2 + 2) / 3 )) # ceiling of 2/3
2640
+ # Effective threshold honors the operator's COUNCIL_THRESHOLD as a tighten-only
2641
+ # floor-raise over the 2/3 ceiling, computed against the actual member count.
2642
+ # This is the PRIMARY completion-decision path (council_should_stop ->
2643
+ # council_evaluate -> council_aggregate_votes), so it must use the same helper
2644
+ # as council_vote, not a separate hardcoded formula.
2645
+ local threshold
2646
+ threshold=$(_council_effective_threshold "$total_members")
2608
2647
  local verdict="CONTINUE"
2609
2648
  if [ "$complete_count" -ge "$threshold" ]; then
2610
2649
  verdict="COMPLETE"
@@ -2833,7 +2872,8 @@ council_evaluate() {
2833
2872
  fi
2834
2873
 
2835
2874
  # Compute threshold using the same ceiling(2/3) formula as council_vote and council_aggregate_votes
2836
- local _eval_threshold=$(( (COUNCIL_SIZE * 2 + 2) / 3 ))
2875
+ local _eval_threshold
2876
+ _eval_threshold=$(_council_effective_threshold)
2837
2877
 
2838
2878
  # Step 1: Aggregate votes from all members.
2839
2879
  # Phase C (v7.5.20): try the Claude `--agents <json>` + `--json-schema`
@@ -162,7 +162,14 @@ hook_pre_file_edit() {
162
162
  [[ -z "$file_path" ]] && return 0
163
163
  local snap_base
164
164
  snap_base=$(_migration_snapshot_dir)
165
- _heal_snapshot_save "$snap_base" "$file_path"
165
+ # Enforce the pairing contract: if the snapshot cannot be captured, BLOCK the
166
+ # edit. Proceeding would leave post_file_edit's block_and_rollback path with
167
+ # no snapshot to restore -- a test failure would then leave the broken edit
168
+ # in place (silent revert failure). Fail loud/closed here instead.
169
+ if ! _heal_snapshot_save "$snap_base" "$file_path"; then
170
+ echo "HOOK_BLOCKED: could not capture a pre-edit snapshot for ${file_path}; refusing to edit without a revert path. Check that ${snap_base}/snapshots is writable."
171
+ return 1
172
+ fi
166
173
  return 0
167
174
  }
168
175
 
@@ -418,7 +425,15 @@ print('OK')
418
425
  # Capture a pre-edit snapshot so post_healing_modify can revert ONLY the
419
426
  # healing edit on test failure (not unrelated uncommitted changes, and not
420
427
  # via git checkout which discards everything). Keyed by file path.
421
- _heal_snapshot_save "$heal_dir" "$file_path"
428
+ #
429
+ # Enforce the pairing contract: if the snapshot cannot be captured, BLOCK the
430
+ # edit. Proceeding would leave the edit with no revert path -- a later test
431
+ # failure would then leave the broken edit in place while honestly reporting
432
+ # "no snapshot" (silent revert failure). Fail loud/closed here instead.
433
+ if ! _heal_snapshot_save "$heal_dir" "$file_path"; then
434
+ echo "HOOK_BLOCKED: could not capture a pre-edit snapshot for ${file_path}; refusing to modify it without a revert path. Check that ${heal_dir}/snapshots is writable."
435
+ return 1
436
+ fi
422
437
 
423
438
  return 0
424
439
  }
@@ -438,27 +453,63 @@ _heal_snapshot_path() {
438
453
  # healing edit will CREATE it), write a sentinel marker instead so the revert
439
454
  # path knows to remove the file rather than restore content.
440
455
  #
441
- # Pairing contract: hook_pre_healing_modify (which calls this) MUST run for a
442
- # file before hook_post_healing_modify reverts it. The snapshot is refreshed on
456
+ # Pairing contract (ENFORCED, fail-closed): hook_pre_healing_modify (which calls
457
+ # this) MUST run for a file before hook_post_healing_modify reverts it, AND that
458
+ # pre call MUST leave behind a revertable snapshot. The snapshot is refreshed on
443
459
  # every pre call, so a post without a matching fresh pre could restore a stale
444
460
  # blob. On the success path the snapshot is intentionally left in place; the
445
461
  # next pre overwrites it.
462
+ #
463
+ # Returns 0 ONLY when a revertable snapshot demonstrably exists on disk after
464
+ # this call: exactly one of {content snapshot, ".absent" marker}. Returns 1 on
465
+ # ANY failure (cannot create the snapshot dir, cp/sentinel write failed, the
466
+ # wrong marker survived, or both/neither markers exist). A 1 return MUST block
467
+ # the edit at the caller -- otherwise the edit proceeds with no revert path and a
468
+ # later test failure leaves the broken edit in place (the silent-revert-failure
469
+ # this guard exists to prevent). NEVER return 0 on a failure path.
446
470
  _heal_snapshot_save() {
447
471
  local heal_dir="$1"
448
472
  local file_path="$2"
473
+ # Empty file_path: nothing to snapshot. Callers treat empty file_path as a
474
+ # no-op (return 0 before reaching here in the hooks); not a contract breach.
449
475
  [[ -z "$file_path" ]] && return 0
450
476
  local snap_dir="$heal_dir/snapshots"
451
- mkdir -p "$snap_dir" 2>/dev/null || return 0
477
+ if ! mkdir -p "$snap_dir" 2>/dev/null || [[ ! -d "$snap_dir" ]]; then
478
+ return 1
479
+ fi
452
480
  local snap
453
481
  snap=$(_heal_snapshot_path "$heal_dir" "$file_path")
454
482
  if [[ -f "$file_path" ]]; then
455
- cp "$file_path" "$snap" 2>/dev/null || return 0
456
- rm -f "$snap.absent" 2>/dev/null || true
483
+ # Capture content, then drop any stale absent-marker so EXACTLY the
484
+ # content snapshot survives. Fail closed if either step fails.
485
+ if ! cp "$file_path" "$snap" 2>/dev/null; then
486
+ return 1
487
+ fi
488
+ if ! rm -f "$snap.absent" 2>/dev/null; then
489
+ return 1
490
+ fi
491
+ # Verify exactly the content snapshot exists and the absent-marker is
492
+ # gone. A lingering marker is a contract violation (restore checks the
493
+ # content snapshot first, but the inconsistency must not be tolerated).
494
+ if [[ ! -f "$snap" || -f "$snap.absent" ]]; then
495
+ return 1
496
+ fi
457
497
  else
458
498
  # File does not exist pre-edit: record an "absent" marker, drop any
459
- # stale content snapshot.
460
- rm -f "$snap" 2>/dev/null || true
461
- : > "$snap.absent" 2>/dev/null || true
499
+ # stale content snapshot. CRITICAL: if the stale content snapshot is not
500
+ # removed, restore checks the content snapshot FIRST and would restore
501
+ # content for a file that should have been REMOVED. Fail closed if the
502
+ # stale snapshot cannot be cleared or the marker cannot be written.
503
+ if ! rm -f "$snap" 2>/dev/null; then
504
+ return 1
505
+ fi
506
+ if ! : > "$snap.absent" 2>/dev/null; then
507
+ return 1
508
+ fi
509
+ # Verify exactly the absent-marker exists and no content snapshot does.
510
+ if [[ ! -f "$snap.absent" || -f "$snap" ]]; then
511
+ return 1
512
+ fi
462
513
  fi
463
514
  return 0
464
515
  }
package/autonomy/run.sh CHANGED
@@ -2058,6 +2058,25 @@ validate_api_keys() {
2058
2058
  return 1
2059
2059
  fi
2060
2060
 
2061
+ # Zero-friction auth preflight for LOCAL runs (must run BEFORE the early
2062
+ # return below, which exits for non-Docker/K8s envs). When claude is the
2063
+ # provider, the claude CLI is on PATH, there is no ANTHROPIC_API_KEY, and no
2064
+ # OAuth credentials file exists (the user installed claude but never ran
2065
+ # `claude login`), the build would otherwise enter, make a failing call, and
2066
+ # 401 -- the worst first impression -- forcing the user to run `loki why`.
2067
+ # Fail fast with the one-step fix instead. Opt out with LOKI_SKIP_AUTH_PREFLIGHT=1.
2068
+ if [[ "$provider" == "claude" && "${LOKI_SKIP_AUTH_PREFLIGHT:-}" != "1" && -z "${ANTHROPIC_API_KEY:-}" ]] \
2069
+ && command -v claude >/dev/null 2>&1; then
2070
+ local _local_creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
2071
+ if [[ ! -s "$_local_creds" ]]; then
2072
+ log_error "Claude Code is installed but not logged in -- the build would stall instead of running."
2073
+ log_error "Log in once, then retry:"
2074
+ log_error " claude login"
2075
+ log_error "(or set ANTHROPIC_API_KEY, or LOKI_SKIP_AUTH_PREFLIGHT=1 to bypass this check)"
2076
+ return 1
2077
+ fi
2078
+ fi
2079
+
2061
2080
  # CLI tools (claude, codex, cline, aider) use their own login sessions.
2062
2081
  # Only require API keys inside Docker/K8s where CLI login isn't available.
2063
2082
  if [[ ! -f "/.dockerenv" ]] && [[ -z "${KUBERNETES_SERVICE_HOST:-}" ]]; then
@@ -2162,6 +2181,9 @@ except Exception:
2162
2181
  return 1
2163
2182
  fi
2164
2183
  fi
2184
+ # NOTE: the never-logged-in (no credentials file) case is handled earlier,
2185
+ # BEFORE the local early-return, so it covers local runs too (this Docker/
2186
+ # K8s-only block would otherwise be unreachable for that case).
2165
2187
  fi
2166
2188
 
2167
2189
  return 0
@@ -3801,6 +3823,82 @@ effective_session_cap() {
3801
3823
  return 0
3802
3824
  }
3803
3825
 
3826
+ # Auto-flag parity for parallel worktree Claude sessions (wave-5 fix).
3827
+ # The main RARV loop applies adaptive cost/resilience flags to its claude
3828
+ # invocation (run.sh:16007-16030 effort/max-budget/fallback) plus an MCP
3829
+ # bundle, but the parallel worktree spawn shipped a bare invocation that missed
3830
+ # them entirely. This populates the global _LOKI_WT_AUTO_FLAGS array with ONLY
3831
+ # the flags that are safe on the plain (non stream-json) worktree invocation:
3832
+ # --effort adaptive reasoning depth (dev tier)
3833
+ # --max-budget-usd per-call hard backstop
3834
+ # --fallback-model resilience to model overload/unavailability
3835
+ # --mcp-config (+ --strict-mcp-config) the same MCP bundle the Bun
3836
+ # route emits; a SUPERSET of the bash main loop, which does
3837
+ # NOT emit --mcp-config (see ledger run.sh:15990-15996).
3838
+ # Worktree dev streams benefit from the bundled MCP servers.
3839
+ # We deliberately do NOT replicate the stream-json-coupled flags
3840
+ # (--include-hook-events, --include-partial-messages) nor --output-format /
3841
+ # --session-id: this invocation logs free-form text, not parsed stream-json, so
3842
+ # those would be invalid or unwanted here. Each flag is gated on CLI support +
3843
+ # its opt-out env var, matching the main loop. Extracted from spawn_worktree_session
3844
+ # so it is unit-testable (tests/test-worktree-auto-flags.sh).
3845
+ _loki_build_worktree_claude_flags() {
3846
+ _LOKI_WT_AUTO_FLAGS=()
3847
+ # Non-claude providers never receive these flags.
3848
+ if [ "${PROVIDER_NAME:-claude}" != "claude" ]; then
3849
+ return 0
3850
+ fi
3851
+ # Dev-tier model param drives the fallback derivation (worktree streams are
3852
+ # development work). Resolve via the provider helper when available.
3853
+ local _loki_wt_primary=""
3854
+ if type provider_get_tier_param >/dev/null 2>&1; then
3855
+ _loki_wt_primary="$(provider_get_tier_param development 2>/dev/null || true)"
3856
+ fi
3857
+ if [ "${LOKI_AUTO_EFFORT:-on}" != "off" ] \
3858
+ && type loki_effort_for_tier >/dev/null 2>&1 \
3859
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
3860
+ && loki_claude_flag_supported "--effort"; then
3861
+ local _loki_wt_effort
3862
+ _loki_wt_effort="$(loki_effort_for_tier development "${DETECTED_COMPLEXITY:-${LOKI_COMPLEXITY:-standard}}")"
3863
+ [ -n "$_loki_wt_effort" ] && _LOKI_WT_AUTO_FLAGS+=("--effort" "$_loki_wt_effort")
3864
+ fi
3865
+ if [ "${LOKI_AUTO_BUDGET:-on}" != "off" ] \
3866
+ && type loki_remaining_budget >/dev/null 2>&1 \
3867
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
3868
+ && loki_claude_flag_supported "--max-budget-usd"; then
3869
+ local _loki_wt_rem
3870
+ _loki_wt_rem="$(loki_remaining_budget)"
3871
+ [ -n "$_loki_wt_rem" ] && _LOKI_WT_AUTO_FLAGS+=("--max-budget-usd" "$_loki_wt_rem")
3872
+ fi
3873
+ if [ "${LOKI_AUTO_FALLBACK:-on}" != "off" ] \
3874
+ && [ -n "$_loki_wt_primary" ] \
3875
+ && type loki_fallback_for_primary >/dev/null 2>&1 \
3876
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
3877
+ && loki_claude_flag_supported "--fallback-model"; then
3878
+ local _loki_wt_fb
3879
+ _loki_wt_fb="$(loki_fallback_for_primary "$_loki_wt_primary")"
3880
+ [ -n "$_loki_wt_fb" ] && _LOKI_WT_AUTO_FLAGS+=("--fallback-model" "$_loki_wt_fb")
3881
+ fi
3882
+ if type loki_mcp_config_argv >/dev/null 2>&1 \
3883
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
3884
+ && loki_claude_flag_supported "--mcp-config"; then
3885
+ local _loki_wt_mcp
3886
+ if _loki_wt_mcp="$(loki_mcp_config_argv)" && [ -n "$_loki_wt_mcp" ]; then
3887
+ _LOKI_WT_AUTO_FLAGS+=("--mcp-config")
3888
+ local _loki_wt_mcp_path
3889
+ for _loki_wt_mcp_path in $_loki_wt_mcp; do
3890
+ _LOKI_WT_AUTO_FLAGS+=("$_loki_wt_mcp_path")
3891
+ done
3892
+ # --strict-mcp-config only alongside a real bundle, never bare.
3893
+ if [ "${LOKI_STRICT_MCP:-1}" != "0" ] \
3894
+ && loki_claude_flag_supported "--strict-mcp-config"; then
3895
+ _LOKI_WT_AUTO_FLAGS+=("--strict-mcp-config")
3896
+ fi
3897
+ fi
3898
+ fi
3899
+ return 0
3900
+ }
3901
+
3804
3902
  # Spawn a Claude session in a worktree
3805
3903
  spawn_worktree_session() {
3806
3904
  local stream_name="$1"
@@ -3843,6 +3941,11 @@ spawn_worktree_session() {
3843
3941
 
3844
3942
  log_step "Spawning ${PROVIDER_DISPLAY_NAME:-Claude} session: $stream_name"
3845
3943
 
3944
+ # Build the worktree auto-flag set (effort/max-budget/fallback/mcp-config)
3945
+ # BEFORE the ( subshell so it is inherited (a `bash -c` would not). Populates
3946
+ # the global _LOKI_WT_AUTO_FLAGS array. See _loki_build_worktree_claude_flags.
3947
+ _loki_build_worktree_claude_flags
3948
+
3846
3949
  (
3847
3950
  cd "$worktree_path" || exit 1
3848
3951
  _wt_exit=0
@@ -3858,12 +3961,17 @@ spawn_worktree_session() {
3858
3961
  if type loki_caveman_activate_env >/dev/null 2>&1; then
3859
3962
  _loki_wt_cm="$(loki_caveman_activate_env)"
3860
3963
  fi
3964
+ # Expand the auto-flag array with the bash-3.2 empty-array guard
3965
+ # (${arr[@]+...}) so a bare "${arr[@]}" under set -u does not
3966
+ # abort with "unbound variable" when no flags were collected.
3861
3967
  if [ -n "$_loki_wt_cm" ]; then
3862
3968
  CAVEMAN_DEFAULT_MODE="$_loki_wt_cm" claude --dangerously-skip-permissions \
3969
+ "${_LOKI_WT_AUTO_FLAGS[@]+"${_LOKI_WT_AUTO_FLAGS[@]}"}" \
3863
3970
  -p "Loki Mode: $task_prompt. Read .loki/CONTINUITY.md for context." \
3864
3971
  >> "$log_file" 2>&1 || _wt_exit=$?
3865
3972
  else
3866
3973
  claude --dangerously-skip-permissions \
3974
+ "${_LOKI_WT_AUTO_FLAGS[@]+"${_LOKI_WT_AUTO_FLAGS[@]}"}" \
3867
3975
  -p "Loki Mode: $task_prompt. Read .loki/CONTINUITY.md for context." \
3868
3976
  >> "$log_file" 2>&1 || _wt_exit=$?
3869
3977
  fi
@@ -12138,16 +12246,31 @@ check_task_completion_signal() {
12138
12246
  local fb_statement="${fb_content:-All PRD requirements implemented and tests passing}"
12139
12247
  # Build minimal JSON payload
12140
12248
  signal_file="$fallback_file"
12141
- # Write the synthesized payload back into the signal file so the
12142
- # rest of this function can read it uniformly.
12143
- python3 -c "
12249
+ # Synthesize the payload into a TEMP file then atomically move it into
12250
+ # place, so the signal file is never observed truncated/empty/malformed:
12251
+ # a bare `python3 ... > "$fallback_file"` truncates the file BEFORE python
12252
+ # runs, so a python crash (or missing interpreter) mid-write would leave
12253
+ # an empty/partial file that downstream json.loads then silently drops.
12254
+ local _fb_tmp="${fallback_file}.tmp.$$"
12255
+ if python3 -c "
12144
12256
  import json, sys
12145
- print(json.dumps({
12257
+ sys.stdout.write(json.dumps({
12146
12258
  'statement': sys.argv[1][:1000],
12147
12259
  'evidence': 'file-based completion via COMPLETION_REQUESTED fallback',
12148
12260
  'confidence': 'medium',
12149
12261
  'source': 'completion_requested_file_fallback'
12150
- }))" "$fb_statement" > "$fallback_file" 2>/dev/null || echo '{}' > "$fallback_file"
12262
+ }))" "$fb_statement" > "$_fb_tmp" 2>/dev/null && [ -s "$_fb_tmp" ]; then
12263
+ mv -f "$_fb_tmp" "$fallback_file" 2>/dev/null || rm -f "$_fb_tmp" 2>/dev/null
12264
+ else
12265
+ # python unavailable or produced nothing: write a hand-built minimal
12266
+ # valid JSON (statement embedded with a conservative escape) atomically.
12267
+ rm -f "$_fb_tmp" 2>/dev/null
12268
+ local _fb_esc
12269
+ _fb_esc=$(printf '%s' "$fb_statement" | tr -d '"\\\n\r' | cut -c1-1000)
12270
+ printf '{"statement":"%s","evidence":"file-based completion via COMPLETION_REQUESTED fallback","confidence":"medium","source":"completion_requested_file_fallback"}' "$_fb_esc" > "${fallback_file}.tmp2.$$" 2>/dev/null \
12271
+ && mv -f "${fallback_file}.tmp2.$$" "$fallback_file" 2>/dev/null \
12272
+ || { rm -f "${fallback_file}.tmp2.$$" 2>/dev/null; printf '{"statement":"completion requested","evidence":"fallback","confidence":"low","source":"completion_requested_file_fallback"}' > "$fallback_file" 2>/dev/null; }
12273
+ fi
12151
12274
  fi
12152
12275
 
12153
12276
  if [ ! -f "$signal_file" ]; then
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.96.0"
10
+ __version__ = "7.98.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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.96.0
5
+ **Version:** v7.98.0
6
6
 
7
7
  ---
8
8
 
package/events/bus.py CHANGED
@@ -130,6 +130,16 @@ class LokiEvent:
130
130
  # dedup (_processed_ids + pending-file glob) and re-importing every flat
131
131
  # line on each call. Derive a DETERMINISTIC id from the record content
132
132
  # so repeated imports of the same line are idempotent.
133
+ #
134
+ # Width: the digest source is user-controllable (timestamp + type +
135
+ # payload). An 8-hex-char (32-bit) truncation collides at the birthday
136
+ # bound around ~77k distinct events (sqrt(2^32)); a collision makes two
137
+ # DISTINCT events share an id, so dedup (_processed_ids + pending glob)
138
+ # silently DROPS the second event. Widen to 16 hex chars (64 bits) so
139
+ # the collision bound moves to ~4 billion distinct events -- well past
140
+ # any realistic events.jsonl. This deterministic-derivation path is
141
+ # Python-only (bus.ts has no jsonl import and mints a random id when one
142
+ # is absent), so widening here needs no cross-language parity change.
133
143
  event_id = data.get('id', '')
134
144
  if not event_id:
135
145
  digest_src = '%s|%s|%s' % (
@@ -137,7 +147,7 @@ class LokiEvent:
137
147
  raw_type,
138
148
  json.dumps(payload, sort_keys=True),
139
149
  )
140
- event_id = hashlib.sha1(digest_src.encode('utf-8')).hexdigest()[:8]
150
+ event_id = hashlib.sha1(digest_src.encode('utf-8')).hexdigest()[:16]
141
151
 
142
152
  return cls(
143
153
  id=event_id,
package/events/emit.sh CHANGED
@@ -119,16 +119,27 @@ fi
119
119
 
120
120
  # JSON escape helper: handles \, ", and control characters including newlines
121
121
  #
122
- # The sed pass escapes the named short forms (\\ \" \t \r \b \f); the first awk
123
- # pass collapses embedded newlines to \n. Any OTHER C0 control byte
124
- # (0x01-0x07, 0x0B, 0x0E-0x1F) is invalid raw inside a JSON string and is
125
- # escaped as \uXXXX by the final awk pass -- otherwise json.loads / JSON.parse
126
- # reject the line and consumers (dashboard _read_events, learning aggregator)
127
- # silently drop it. The named short forms are already two-char ASCII by the
128
- # time the final pass runs, so they are never re-escaped.
122
+ # The sed pass escapes backslash, double-quote, tab and carriage-return to
123
+ # their named short forms (\\ \" \t \r); the first awk pass collapses embedded
124
+ # newlines to \n. Every REMAINING C0 control byte (0x01-0x08, 0x0B-0x0C,
125
+ # 0x0E-0x1F -- which includes backspace 0x08 and form-feed 0x0C) is invalid raw
126
+ # inside a JSON string and is escaped as \uXXXX by the final awk pass (its map
127
+ # covers all of i=1..31). Without that escaping json.loads / JSON.parse reject
128
+ # the line and consumers (dashboard _read_events, learning aggregator) silently
129
+ # drop it. The named short forms emitted by sed are already two-char ASCII by
130
+ # the time the final pass runs, so they are never re-escaped.
131
+ #
132
+ # NOTE: backspace/form-feed are deliberately NOT escaped in the sed pass.
133
+ # Doing so requires embedding the raw control bytes in the sed pattern, which
134
+ # editors silently strip -- that left the two empty-pattern sed no-ops this
135
+ # helper previously carried. An empty sed regex reuses the LAST applied regex,
136
+ # so those entries were dead AND a latent corruption hazard. The final awk
137
+ # pass already escapes every byte in i=1..31 (backspace and form-feed included)
138
+ # to the six-char uXXXX unicode escape, producing valid JSON, so the
139
+ # named-short-form variants are unnecessary.
129
140
  json_escape() {
130
141
  printf '%s' "$1" \
131
- | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g; s//\\b/g; s//\\f/g' \
142
+ | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' \
132
143
  | awk '{if(NR>1) printf "\\n"; printf "%s", $0}' \
133
144
  | awk 'BEGIN{for(i=1;i<=31;i++)m[sprintf("%c",i)]=sprintf("\\u%04x",i)}
134
145
  {s=""; n=length($0);
@@ -163,9 +174,23 @@ EVENT=$(cat <<EOF
163
174
  EOF
164
175
  )
165
176
 
166
- # Write event file
177
+ # Write event file atomically.
178
+ #
179
+ # A bare `echo "$EVENT" > "$EVENT_FILE"` is NON-atomic: a reader (bus.py
180
+ # get_pending_events / from_dict, dashboard) that globs `*.json` mid-write sees
181
+ # a truncated/partial file and either fails json.loads (event silently dropped)
182
+ # or, worse, reads a half-written record. Write to a sibling temp file in the
183
+ # SAME directory (so rename(2) is an atomic same-filesystem operation) and then
184
+ # rename into place; a `.tmp` suffix keeps the in-progress file out of the
185
+ # `*.json` glob readers use. Clean up the temp on any failure so partials never
186
+ # linger.
167
187
  EVENT_FILE="$EVENTS_DIR/${TIMESTAMP//:/-}_$EVENT_ID.json"
168
- echo "$EVENT" > "$EVENT_FILE"
188
+ EVENT_TMP="$EVENT_FILE.tmp"
189
+ if printf '%s\n' "$EVENT" > "$EVENT_TMP" && mv -f "$EVENT_TMP" "$EVENT_FILE"; then
190
+ :
191
+ else
192
+ rm -f "$EVENT_TMP" 2>/dev/null || true
193
+ fi
169
194
 
170
195
  # Rotate events.jsonl if it exceeds 50MB (keep 1 backup)
171
196
  EVENTS_LOG="$LOKI_DIR/events.jsonl"
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.96.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=H8(G8(import.meta.url)),Z=e$(Q);Q$=W8(U8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var u1={};b(u1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>f1,commandVersion:()=>N8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>m1});async function f1($,Q=m1){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([f1(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=E8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function E8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function N8($,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 m1=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 r($){return S8?"":$}var S8,T,S,_,lZ,I,k,y,J;var c=L(()=>{S8=(process.env.NO_COLOR??"").length>0;T=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),lZ=r("\x1B[0;34m"),I=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as u8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(u8($))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 q0={};b(q0,{runStatus:()=>W3});import{existsSync as v,readFileSync as U$,readdirSync as i1,statSync as e1}from"fs";import{resolve as D,basename as $3}from"path";import{homedir as Q3}from"os";function $0($){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 Q0($,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=$0($),U=$0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function z3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
2
+ var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.98.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=H8(G8(import.meta.url)),Z=e$(Q);Q$=W8(U8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var u1={};b(u1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>f1,commandVersion:()=>N8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>m1});async function f1($,Q=m1){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([f1(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=E8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function E8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function N8($,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 m1=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 r($){return S8?"":$}var S8,T,S,_,lZ,I,k,y,J;var c=L(()=>{S8=(process.env.NO_COLOR??"").length>0;T=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),lZ=r("\x1B[0;34m"),I=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as u8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(u8($))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 q0={};b(q0,{runStatus:()=>W3});import{existsSync as v,readFileSync as U$,readdirSync as i1,statSync as e1}from"fs";import{resolve as D,basename as $3}from"path";import{homedir as Q3}from"os";function $0($){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 Q0($,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=$0($),U=$0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function z3(){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)
@@ -802,4 +802,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
802
802
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
803
803
  `),process.stderr.write(Z8),2}}r1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var FZ=await jZ(Bun.argv.slice(2));process.exit(FZ);
804
804
 
805
- //# debugId=E60A742D72C38FD764756E2164756E21
805
+ //# debugId=5C332E0B9079B5FC64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.96.0'
60
+ __version__ = '7.98.0'
package/memory/engine.py CHANGED
@@ -1034,6 +1034,39 @@ class MemoryEngine:
1034
1034
 
1035
1035
  return None
1036
1036
 
1037
+ @staticmethod
1038
+ def _parse_optional_datetime(
1039
+ raw: Any, *, record_id: str = "<unknown>", field: str = "last_accessed"
1040
+ ) -> Optional[datetime]:
1041
+ """Tolerantly parse an optional ISO datetime field off a stored record.
1042
+
1043
+ A corrupt/non-ISO value on ONE record must not raise out of the
1044
+ _dict_to_* converters and crash the whole batch list-comp in
1045
+ get_recent_episodes / find_patterns / list_skills (which would drop EVERY
1046
+ item in the scan, not just the bad one) on the RARV retrieval hot path.
1047
+ An unparseable value falls back to None (treated as never-accessed) so
1048
+ the record is still returned and retrievable.
1049
+
1050
+ Returns a tz-aware datetime (UTC assumed when naive), or None when the
1051
+ value is missing/empty/unparseable.
1052
+ """
1053
+ if not raw:
1054
+ return None
1055
+ if isinstance(raw, datetime):
1056
+ return raw.replace(tzinfo=timezone.utc) if raw.tzinfo is None else raw
1057
+ if isinstance(raw, str):
1058
+ value = raw[:-1] if raw.endswith("Z") else raw
1059
+ try:
1060
+ parsed = datetime.fromisoformat(value)
1061
+ except ValueError:
1062
+ logger.warning(
1063
+ "Record %s has unparseable %s %r; treating as never-accessed",
1064
+ record_id, field, raw,
1065
+ )
1066
+ return None
1067
+ return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
1068
+ return None
1069
+
1037
1070
  def _dict_to_episode(self, data: Dict[str, Any]) -> EpisodeTrace:
1038
1071
  """Convert dictionary to EpisodeTrace."""
1039
1072
  # Parse timestamp string to datetime
@@ -1081,20 +1114,13 @@ class MemoryEngine:
1081
1114
  for e in errors_raw
1082
1115
  ]
1083
1116
 
1084
- # Parse last_accessed datetime
1085
- last_accessed = None
1086
- last_accessed_raw = data.get("last_accessed")
1087
- if last_accessed_raw:
1088
- if isinstance(last_accessed_raw, str):
1089
- if last_accessed_raw.endswith("Z"):
1090
- last_accessed_raw = last_accessed_raw[:-1]
1091
- last_accessed = datetime.fromisoformat(last_accessed_raw)
1092
- if last_accessed.tzinfo is None:
1093
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1094
- elif isinstance(last_accessed_raw, datetime):
1095
- last_accessed = last_accessed_raw
1096
- if last_accessed.tzinfo is None:
1097
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1117
+ # Parse last_accessed datetime (tolerant: a corrupt value falls back to
1118
+ # None so a single bad episode never crashes the retrieval batch).
1119
+ last_accessed = self._parse_optional_datetime(
1120
+ data.get("last_accessed"),
1121
+ record_id=data.get("id", "<unknown>"),
1122
+ field="last_accessed",
1123
+ )
1098
1124
 
1099
1125
  return EpisodeTrace(
1100
1126
  id=data.get("id", ""),
@@ -1119,21 +1145,14 @@ class MemoryEngine:
1119
1145
 
1120
1146
  def _dict_to_pattern(self, data: Dict[str, Any]) -> SemanticPattern:
1121
1147
  """Convert dictionary to SemanticPattern."""
1122
- # Parse last_used string to datetime or None
1123
- last_used = None
1124
- last_used_raw = data.get("last_used")
1125
- if last_used_raw:
1126
- if isinstance(last_used_raw, str):
1127
- # Handle ISO format with Z suffix
1128
- if last_used_raw.endswith("Z"):
1129
- last_used_raw = last_used_raw[:-1]
1130
- last_used = datetime.fromisoformat(last_used_raw)
1131
- if last_used.tzinfo is None:
1132
- last_used = last_used.replace(tzinfo=timezone.utc)
1133
- elif isinstance(last_used_raw, datetime):
1134
- last_used = last_used_raw
1135
- if last_used.tzinfo is None:
1136
- last_used = last_used.replace(tzinfo=timezone.utc)
1148
+ # Parse last_used string to datetime or None (tolerant: a corrupt value
1149
+ # on one pattern must not crash the find_patterns retrieval batch; it
1150
+ # shares the same hot path and crash mechanism as last_accessed below).
1151
+ last_used = self._parse_optional_datetime(
1152
+ data.get("last_used"),
1153
+ record_id=data.get("id", "<unknown>"),
1154
+ field="last_used",
1155
+ )
1137
1156
 
1138
1157
  # Convert links dicts to Link objects
1139
1158
  links_raw = data.get("links", [])
@@ -1142,20 +1161,12 @@ class MemoryEngine:
1142
1161
  for link in links_raw
1143
1162
  ]
1144
1163
 
1145
- # Parse last_accessed datetime
1146
- last_accessed = None
1147
- last_accessed_raw = data.get("last_accessed")
1148
- if last_accessed_raw:
1149
- if isinstance(last_accessed_raw, str):
1150
- if last_accessed_raw.endswith("Z"):
1151
- last_accessed_raw = last_accessed_raw[:-1]
1152
- last_accessed = datetime.fromisoformat(last_accessed_raw)
1153
- if last_accessed.tzinfo is None:
1154
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1155
- elif isinstance(last_accessed_raw, datetime):
1156
- last_accessed = last_accessed_raw
1157
- if last_accessed.tzinfo is None:
1158
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1164
+ # Parse last_accessed datetime (tolerant: see _dict_to_episode).
1165
+ last_accessed = self._parse_optional_datetime(
1166
+ data.get("last_accessed"),
1167
+ record_id=data.get("id", "<unknown>"),
1168
+ field="last_accessed",
1169
+ )
1159
1170
 
1160
1171
  return SemanticPattern(
1161
1172
  id=data.get("id", ""),
@@ -1182,20 +1193,12 @@ class MemoryEngine:
1182
1193
  for e in raw_errors
1183
1194
  ]
1184
1195
 
1185
- # Parse last_accessed datetime
1186
- last_accessed = None
1187
- last_accessed_raw = data.get("last_accessed")
1188
- if last_accessed_raw:
1189
- if isinstance(last_accessed_raw, str):
1190
- if last_accessed_raw.endswith("Z"):
1191
- last_accessed_raw = last_accessed_raw[:-1]
1192
- last_accessed = datetime.fromisoformat(last_accessed_raw)
1193
- if last_accessed.tzinfo is None:
1194
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1195
- elif isinstance(last_accessed_raw, datetime):
1196
- last_accessed = last_accessed_raw
1197
- if last_accessed.tzinfo is None:
1198
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1196
+ # Parse last_accessed datetime (tolerant: see _dict_to_episode).
1197
+ last_accessed = self._parse_optional_datetime(
1198
+ data.get("last_accessed"),
1199
+ record_id=data.get("id", "<unknown>"),
1200
+ field="last_accessed",
1201
+ )
1199
1202
 
1200
1203
  return ProceduralSkill(
1201
1204
  id=data.get("id", ""),
@@ -9,10 +9,66 @@ for injection into the RARV prompt cycle.
9
9
 
10
10
  import argparse
11
11
  import json
12
+ import re
12
13
  import sys
13
14
  from pathlib import Path
14
15
 
15
16
 
17
+ # Per-field hard cap so a single oversized/garbage memory entry cannot dominate
18
+ # the injected context or push real signal out of the token budget.
19
+ _MAX_FIELD_CHARS = 600
20
+
21
+ # Control characters (excluding ordinary whitespace handled separately) that
22
+ # must never reach the prompt: they can corrupt rendering or smuggle hidden
23
+ # directives. Covers C0 controls and DEL.
24
+ _CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
25
+
26
+
27
+ def _sanitize_field(value, max_chars=_MAX_FIELD_CHARS):
28
+ """Neutralize a single field from stored memory before it enters a prompt.
29
+
30
+ Memory entries are untrusted input: a malicious or garbage pattern could
31
+ contain prompt-injection payloads (fake instructions, fabricated section
32
+ headers, code-fence breakers, or directives that try to override the RARV
33
+ prompt). This collapses each field to a single safe inline line so it can
34
+ only ever read as data, never as structure or instructions.
35
+
36
+ Steps:
37
+ - Coerce non-strings to str (a dict/list value would otherwise break
38
+ formatting or interpolate unexpected structure).
39
+ - Drop control characters (keeps the visible text intact).
40
+ - Collapse ALL newlines/tabs/carriage-returns to single spaces so the
41
+ field cannot add new lines, fake "### " section headers, close the
42
+ knowledge block, or open/close a markdown code fence.
43
+ - Defang leading structural markdown markers (#, >, -, *, backticks) so the
44
+ field cannot pose as a heading/list/fence even after collapsing.
45
+ - Truncate to a bounded length.
46
+ """
47
+ if value is None:
48
+ return ''
49
+ if not isinstance(value, str):
50
+ value = str(value)
51
+ # Remove control characters first.
52
+ value = _CONTROL_CHARS_RE.sub('', value)
53
+ # Collapse any vertical whitespace and tabs into single spaces. This is the
54
+ # core injection defense: without newlines the field cannot introduce new
55
+ # markdown structure or standalone instruction lines.
56
+ value = re.sub(r'[\r\n\t]+', ' ', value)
57
+ # Collapse runs of spaces left behind.
58
+ value = re.sub(r' {2,}', ' ', value).strip()
59
+ # Defang leading structural markdown so the field cannot masquerade as a
60
+ # heading, blockquote, list item, or code fence.
61
+ value = re.sub(r'^[#>\-*`]+\s*', '', value)
62
+ # Defang markdown header markers anywhere in the (now single-line) value so
63
+ # a collapsed payload cannot reintroduce a "### " section marker inline.
64
+ # Backticks are neutralized too so a field cannot open/close a code fence.
65
+ value = re.sub(r'#{1,6}\s', '', value)
66
+ value = value.replace('`', '')
67
+ if len(value) > max_chars:
68
+ value = value[:max_chars].rstrip() + '...'
69
+ return value
70
+
71
+
16
72
  def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
17
73
  """Build RAG context from the knowledge graph.
18
74
 
@@ -39,11 +95,20 @@ def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
39
95
  total_chars = 0
40
96
 
41
97
  for p in patterns:
42
- # Support both 'name'/'pattern' and 'description' fields
43
- name = p.get('name', p.get('pattern', 'Unknown Pattern'))
44
- desc = p.get('description', p.get('correct_approach', ''))
45
- category = p.get('category', '')
46
- source = p.get('_source_project', '')
98
+ # Support both 'name'/'pattern' and 'description' fields.
99
+ # Every field is sanitized: memory entries are untrusted input and must
100
+ # not be able to inject instructions or break the prompt structure.
101
+ name = _sanitize_field(p.get('name', p.get('pattern', 'Unknown Pattern')))
102
+ desc = _sanitize_field(p.get('description', p.get('correct_approach', '')))
103
+ category = _sanitize_field(p.get('category', ''))
104
+ source_raw = p.get('_source_project', '')
105
+ # Path().name strips any directory traversal; sanitize the basename too.
106
+ source = _sanitize_field(Path(str(source_raw)).name) if source_raw else ''
107
+
108
+ # name may be empty after sanitization (e.g. a field that was only
109
+ # markdown markers); fall back so the heading is never blank.
110
+ if not name:
111
+ name = 'Unknown Pattern'
47
112
 
48
113
  section = '### ' + name
49
114
  if category:
@@ -52,7 +117,7 @@ def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
52
117
  if desc:
53
118
  section += desc + '\n'
54
119
  if source:
55
- section += '_Source: ' + Path(source).name + '_\n'
120
+ section += '_Source: ' + source + '_\n'
56
121
 
57
122
  if total_chars + len(section) > max_chars:
58
123
  break
@@ -269,6 +269,7 @@ class MemoryRetrieval:
269
269
  vector_indices: Optional[Dict[str, VectorIndex]] = None,
270
270
  base_path: str = ".loki/memory",
271
271
  namespace: Optional[str] = None,
272
+ include_unstamped_legacy: bool = False,
272
273
  ):
273
274
  """
274
275
  Initialize the memory retrieval system.
@@ -279,12 +280,20 @@ class MemoryRetrieval:
279
280
  vector_indices: Optional dict of vector indices (episodic, semantic, skills)
280
281
  base_path: Base path for memory storage directory
281
282
  namespace: Optional namespace for scoped retrieval
283
+ include_unstamped_legacy: Opt-in escape hatch. When False (the
284
+ secure default), legacy entries that lack a "_namespace" stamp
285
+ are EXCLUDED from a namespaced query. This prevents a silent
286
+ cross-namespace leak where one project reads another project's
287
+ unstamped memory. Set True only when migrating a single-project
288
+ store whose entries predate namespace stamping and you have
289
+ verified every entry belongs to the active namespace.
282
290
  """
283
291
  self.storage = storage
284
292
  self.embedding_engine = embedding_engine
285
293
  self.vector_indices = vector_indices or {}
286
294
  self.base_path = Path(base_path)
287
295
  self._namespace = namespace
296
+ self._include_unstamped_legacy = include_unstamped_legacy
288
297
  # Track when indices were last built to detect staleness (BUG-MEM-002).
289
298
  # When consolidation modifies patterns, indices become stale and should
290
299
  # be rebuilt before the next similarity search.
@@ -318,25 +327,45 @@ class MemoryRetrieval:
318
327
  Behavior:
319
328
  - If self._namespace is None, accept all (backward compat for unscoped retrieval).
320
329
  - If result has "_namespace" matching, accept.
321
- - If result lacks "_namespace" (legacy entry written before stamping),
322
- log a deprecation warning (rate-limited) and ACCEPT for backward compat.
323
- - Otherwise, reject.
330
+ - If result lacks "_namespace" (legacy entry written before stamping):
331
+ treat it conservatively. An unstamped entry has no provable origin,
332
+ so under a namespaced query it could belong to ANY namespace and
333
+ including it is a silent cross-namespace leak (one project reading
334
+ another's memory). By default (self._include_unstamped_legacy is
335
+ False) such entries are EXCLUDED, with a rate-limited warning telling
336
+ operators to re-save the entry to add a stamp. Operators who have
337
+ verified a single-project store predates stamping can opt back in via
338
+ include_unstamped_legacy=True.
339
+ - Otherwise (stamp present but does not match), reject.
324
340
  """
325
341
  if self._namespace is None:
326
342
  return True
327
343
  result_ns = result.get("_namespace")
328
344
  if result_ns is None:
329
- # Legacy entry without namespace stamp; accept for backward compat
330
- # but warn so operators can re-save to add stamps.
345
+ # Legacy entry without namespace stamp. No provable origin -> do not
346
+ # silently leak it across namespaces. Exclude by default; warn so
347
+ # operators can re-save to add a stamp (rate-limited to avoid spam).
331
348
  if MemoryRetrieval._legacy_warned_count < MemoryRetrieval._LEGACY_WARN_LIMIT:
332
- logger.warning(
333
- "Memory entry id=%s lacks '_namespace' stamp (legacy "
334
- "entry). Including in results for backward compatibility. "
335
- "Re-save this entry to enable namespace isolation.",
336
- result.get("id", "<unknown>"),
337
- )
349
+ if self._include_unstamped_legacy:
350
+ logger.warning(
351
+ "Memory entry id=%s lacks '_namespace' stamp (legacy "
352
+ "entry). Including under namespace=%s because "
353
+ "include_unstamped_legacy is set. Re-save this entry to "
354
+ "stamp it and remove the opt-in.",
355
+ result.get("id", "<unknown>"),
356
+ self._namespace,
357
+ )
358
+ else:
359
+ logger.warning(
360
+ "Memory entry id=%s lacks '_namespace' stamp (legacy "
361
+ "entry). Excluding from namespace=%s query to prevent a "
362
+ "cross-namespace leak. Re-save this entry to stamp it, "
363
+ "or pass include_unstamped_legacy=True to opt in.",
364
+ result.get("id", "<unknown>"),
365
+ self._namespace,
366
+ )
338
367
  MemoryRetrieval._legacy_warned_count += 1
339
- return True
368
+ return self._include_unstamped_legacy
340
369
  return result_ns == self._namespace
341
370
 
342
371
  def with_namespace(self, namespace: str) -> "MemoryRetrieval":
@@ -361,6 +390,7 @@ class MemoryRetrieval:
361
390
  vector_indices=self.vector_indices,
362
391
  base_path=str(self.base_path),
363
392
  namespace=namespace,
393
+ include_unstamped_legacy=self._include_unstamped_legacy,
364
394
  )
365
395
 
366
396
  # -------------------------------------------------------------------------
package/memory/storage.py CHANGED
@@ -33,6 +33,29 @@ except ImportError:
33
33
  # Default namespace constant
34
34
  DEFAULT_NAMESPACE = "default"
35
35
 
36
+ # Allowed namespace characters. A namespace becomes a single path segment under
37
+ # the memory root, so it must not contain separators, traversal, or whitespace.
38
+ _NAMESPACE_RE = re.compile(r'^[a-zA-Z0-9_-]+$')
39
+
40
+
41
+ def _validate_namespace_charset(namespace: str) -> None:
42
+ """Reject a namespace whose characters could escape its directory.
43
+
44
+ Charset-only check, shared by ``__init__`` and ``with_namespace`` so the two
45
+ validation sites cannot drift. Callers are responsible for deciding whether a
46
+ None/empty namespace is acceptable (it is in ``__init__`` for backward
47
+ compat; it is rejected in ``with_namespace``); this only runs once a concrete
48
+ non-default namespace string is present.
49
+
50
+ Raises:
51
+ ValueError: If the namespace contains anything outside [A-Za-z0-9_-].
52
+ """
53
+ if not _NAMESPACE_RE.match(namespace):
54
+ raise ValueError(
55
+ f"Invalid namespace '{namespace}': "
56
+ "only alphanumeric characters, hyphens, and underscores are allowed"
57
+ )
58
+
36
59
 
37
60
  class MemoryStorage:
38
61
  """
@@ -73,14 +96,11 @@ class MemoryStorage:
73
96
  self._root_path = Path(effective_base)
74
97
  self._namespace = namespace
75
98
 
76
- # Validate namespace to prevent path traversal
99
+ # Validate namespace to prevent path traversal. None/empty is accepted
100
+ # here for backward compat (it selects the default, un-namespaced root);
101
+ # only a concrete non-default namespace is charset-checked.
77
102
  if namespace and namespace != DEFAULT_NAMESPACE:
78
- import re
79
- if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
80
- raise ValueError(
81
- f"Invalid namespace '{namespace}': "
82
- "only alphanumeric characters, hyphens, and underscores are allowed"
83
- )
103
+ _validate_namespace_charset(namespace)
84
104
 
85
105
  # Calculate effective base path (with namespace if specified)
86
106
  if namespace and namespace != DEFAULT_NAMESPACE:
@@ -119,15 +139,21 @@ class MemoryStorage:
119
139
  New MemoryStorage instance for the specified namespace
120
140
 
121
141
  Raises:
122
- ValueError: If namespace contains path traversal characters
123
- """
124
- import re
125
- if namespace and namespace != DEFAULT_NAMESPACE:
126
- if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
127
- raise ValueError(
128
- f"Invalid namespace '{namespace}': "
129
- "only alphanumeric characters, hyphens, and underscores are allowed"
130
- )
142
+ ValueError: If namespace is empty/None/non-string, or contains
143
+ characters outside [A-Za-z0-9_-] (path-traversal defense).
144
+ """
145
+ # with_namespace is an explicit "switch to this namespace" call, so an
146
+ # empty, None, whitespace-only, or non-string namespace is meaningless
147
+ # and must be rejected rather than silently resolving to the default
148
+ # root (which is what __init__ would do with a falsy namespace). Reject,
149
+ # do not normalize: normalization would mask a caller bug.
150
+ if not isinstance(namespace, str) or not namespace.strip():
151
+ raise ValueError(
152
+ f"Invalid namespace {namespace!r}: "
153
+ "must be a non-empty string"
154
+ )
155
+ if namespace != DEFAULT_NAMESPACE:
156
+ _validate_namespace_charset(namespace)
131
157
  return MemoryStorage(
132
158
  base_path=str(self._root_path),
133
159
  namespace=namespace,
@@ -1328,9 +1354,20 @@ class MemoryStorage:
1328
1354
  base = min(1.0, base + 0.05 * min(len(errors), 3))
1329
1355
 
1330
1356
  # Access frequency boost (diminishing returns).
1331
- # `or 0` guards against an explicit null access_count crashing the
1332
- # comparison and log1p call below.
1357
+ # `or 0` guards an explicit null access_count; the isinstance/`< 0`
1358
+ # clamp additionally guards a non-numeric (e.g. a stored "5") or negative
1359
+ # value (corrupt or hand-edited record) reaching the `> 0` comparison and
1360
+ # log1p() below. A bare string raises TypeError on `"5" > 0`, and a
1361
+ # negative <= -1 raises a math domain error in log1p; bool is excluded so
1362
+ # a stray True is not treated as a count of 1. All coerce to 0 (no boost)
1363
+ # so importance scoring never crashes the scan.
1333
1364
  access_count = memory.get("access_count") or 0
1365
+ if (
1366
+ not isinstance(access_count, (int, float))
1367
+ or isinstance(access_count, bool)
1368
+ or access_count < 0
1369
+ ):
1370
+ access_count = 0
1334
1371
  if access_count > 0:
1335
1372
  # Log scale boost, caps at about 0.15 for 100+ accesses
1336
1373
  access_boost = 0.05 * math.log1p(access_count)
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.96.0",
4
+ "version": "7.98.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.96.0",
5
+ "version": "7.98.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",