loki-mode 7.5.17 → 7.5.28

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.
Files changed (47) hide show
  1. package/README.md +10 -9
  2. package/SKILL.md +14 -14
  3. package/VERSION +1 -1
  4. package/autonomy/completion-council.sh +26 -3
  5. package/autonomy/lib/claude-flags.sh +132 -0
  6. package/autonomy/lib/mcp-config.sh +160 -0
  7. package/autonomy/lib/project-graph.sh +685 -0
  8. package/autonomy/lib/voter-agents.sh +356 -0
  9. package/autonomy/loki +108 -111
  10. package/autonomy/run.sh +95 -186
  11. package/bin/loki +12 -1
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/requirements.txt +13 -8
  14. package/dashboard/server.py +33 -15
  15. package/dashboard/static/index.html +298 -299
  16. package/docs/INSTALLATION.md +54 -21
  17. package/docs/retrospectives/v7.5.15-fleet-postmortem.md +325 -0
  18. package/docs/retrospectives/v7.5.15-honesty-audit.md +136 -0
  19. package/docs/retrospectives/v7.5.15-llm-failure-modes.md +49 -0
  20. package/loki-ts/data/finding-schema.json +74 -0
  21. package/loki-ts/data/model-pricing.json +12 -0
  22. package/loki-ts/dist/loki.js +198 -172
  23. package/mcp/__init__.py +1 -1
  24. package/mcp/lsp_proxy.py +713 -0
  25. package/mcp/requirements.txt +9 -3
  26. package/mcp/tests/__init__.py +0 -0
  27. package/mcp/tests/test_lsp_proxy.py +377 -0
  28. package/memory/app_graph.py +153 -0
  29. package/memory/storage.py +6 -1
  30. package/memory/tests/test_app_graph.py +134 -0
  31. package/package.json +4 -3
  32. package/providers/claude.sh +115 -4
  33. package/providers/codex.sh +2 -2
  34. package/providers/loader.sh +4 -4
  35. package/providers/model_catalog.json +0 -9
  36. package/providers/models.sh +1 -2
  37. package/references/multi-provider.md +26 -35
  38. package/references/prompt-repetition.md +1 -1
  39. package/references/quality-control.md +1 -1
  40. package/skills/00-index.md +3 -3
  41. package/skills/model-selection.md +11 -14
  42. package/skills/providers.md +17 -57
  43. package/skills/quality-gates.md +2 -2
  44. package/skills/troubleshooting.md +1 -1
  45. package/src/integrations/github/action-handler.js +3 -2
  46. package/src/protocols/tools/start-project.js +1 -1
  47. package/providers/gemini.sh +0 -343
@@ -0,0 +1,685 @@
1
+ #!/usr/bin/env bash
2
+ # autonomy/lib/project-graph.sh -- Phase F (v7.5.23) helpers.
3
+ #
4
+ # Cross-project context discovery. Walks ONE parent level from the target
5
+ # directory looking for `.loki/app.json` manifests, groups siblings into a
6
+ # single logical app, and exports 3 internal env vars + provides a layered
7
+ # CLAUDE.md walker. No new user-facing CLI surface introduced.
8
+ #
9
+ # Public API:
10
+ # loki_project_graph_discover <target_dir> -- run discovery, export
11
+ # LOKI_PROJECT_GRAPH_* env
12
+ # vars; returns 0 always
13
+ # load_app_graph_context -- emit layered CLAUDE.md text
14
+ # wrapped in LOKI_LAYER
15
+ # markers; empty when no
16
+ # graph
17
+ #
18
+ # Internal helpers (prefixed `_lpg_`):
19
+ # _lpg_walk_siblings <target_dir> <basename>
20
+ # _lpg_parse_app_json <path>
21
+ # _lpg_cache_read <target_dir>
22
+ # _lpg_cache_write <target_dir> <result_json>
23
+ #
24
+ # Internal env vars (NEVER user-facing; only read by run.sh / build_prompt):
25
+ # LOKI_PROJECT_GRAPH_ROOT -- absolute path of parent dir when found
26
+ # LOKI_PROJECT_GRAPH_APP_ID -- resolved app_id slug
27
+ # LOKI_PROJECT_GRAPH_MEMBERS -- colon-separated absolute member paths
28
+
29
+ if [ "${__LOKI_PROJECT_GRAPH_SH_LOADED:-0}" = "1" ]; then
30
+ return 0 2>/dev/null || true
31
+ fi
32
+ __LOKI_PROJECT_GRAPH_SH_LOADED=1
33
+
34
+ # Detect BSD vs GNU stat once (macOS vs Linux). Used for fast cache-hit
35
+ # validation that avoids spawning python3 on the hot path.
36
+ if stat -f '%m' / >/dev/null 2>&1; then
37
+ __LPG_STAT_MTIME="stat -f %m"
38
+ else
39
+ __LPG_STAT_MTIME="stat -c %Y"
40
+ fi
41
+
42
+ # Fixed-name sibling whitelist (case-insensitive lookup).
43
+ __LPG_FIXED_NAMES="ui api web service mobile worker backend frontend shared"
44
+
45
+ # Per-layer + total CLAUDE.md byte caps.
46
+ __LPG_PER_LAYER_CAP=16384
47
+ __LPG_TOTAL_CAP=32768
48
+
49
+ # Resolve absolute path without requiring GNU readlink -f.
50
+ _lpg_abs() {
51
+ local p="${1:-}"
52
+ [ -z "$p" ] && return 1
53
+ if [ -d "$p" ]; then
54
+ ( cd "$p" 2>/dev/null && pwd ) || printf '%s' "$p"
55
+ else
56
+ local d
57
+ d=$(dirname "$p")
58
+ local b
59
+ b=$(basename "$p")
60
+ if [ -d "$d" ]; then
61
+ printf '%s/%s' "$( cd "$d" && pwd )" "$b"
62
+ else
63
+ printf '%s' "$p"
64
+ fi
65
+ fi
66
+ }
67
+
68
+ # Lowercase a string portably (bash 3.2 safe; no ${var,,}).
69
+ _lpg_lower() {
70
+ printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]'
71
+ }
72
+
73
+ # Validate and parse a .loki/app.json file.
74
+ # stdout: "<app_id>" on success; empty on failure.
75
+ # stderr: human-readable reason on failure (caller can capture for logs).
76
+ _lpg_parse_app_json() {
77
+ local path="${1:-}"
78
+ [ -z "$path" ] || [ ! -f "$path" ] && return 1
79
+ python3 - "$path" <<'PYEOF' 2>/dev/null
80
+ import json, re, sys
81
+ path = sys.argv[1]
82
+ try:
83
+ with open(path, 'r', encoding='utf-8') as f:
84
+ data = json.load(f)
85
+ except Exception:
86
+ sys.exit(1)
87
+ if not isinstance(data, dict):
88
+ sys.exit(1)
89
+ sv = data.get('schema_version')
90
+ if sv != 1:
91
+ sys.exit(1)
92
+ app_id = data.get('app_id', '')
93
+ if not isinstance(app_id, str):
94
+ sys.exit(1)
95
+ if not re.match(r'^[a-z0-9-]{3,40}$', app_id):
96
+ sys.exit(1)
97
+ print(app_id)
98
+ PYEOF
99
+ }
100
+
101
+ # Read shared_memory_dir + members[] from a manifest (best effort; empty on miss).
102
+ # stdout: JSON dict {"shared_memory_dir": "...", "members": [...]}
103
+ _lpg_manifest_meta() {
104
+ local path="${1:-}"
105
+ [ -z "$path" ] || [ ! -f "$path" ] && { printf '{}'; return; }
106
+ python3 - "$path" <<'PYEOF' 2>/dev/null || printf '{}'
107
+ import json, sys
108
+ path = sys.argv[1]
109
+ try:
110
+ with open(path, 'r', encoding='utf-8') as f:
111
+ data = json.load(f)
112
+ except Exception:
113
+ print('{}')
114
+ sys.exit(0)
115
+ out = {}
116
+ smd = data.get('shared_memory_dir')
117
+ if isinstance(smd, str) and smd:
118
+ out['shared_memory_dir'] = smd
119
+ mems = data.get('members')
120
+ if isinstance(mems, list):
121
+ out['members'] = [m for m in mems if isinstance(m, str)]
122
+ print(json.dumps(out))
123
+ PYEOF
124
+ }
125
+
126
+ # Enumerate candidate sibling directories under PARENT_DIR.
127
+ # Emits absolute paths, one per line. Excludes the target itself.
128
+ _lpg_walk_siblings() {
129
+ local target_dir="${1:-}"
130
+ local basename_target="${2:-}"
131
+ [ -z "$target_dir" ] || [ -z "$basename_target" ] && return 0
132
+ local parent
133
+ parent=$(dirname "$target_dir")
134
+ [ "$parent" = "$target_dir" ] && return 0
135
+ [ ! -d "$parent" ] && return 0
136
+
137
+ local target_lc
138
+ target_lc=$(_lpg_lower "$basename_target")
139
+ local fixed_lc
140
+ fixed_lc=$(printf '%s' "$__LPG_FIXED_NAMES" | tr '[:upper:]' '[:lower:]')
141
+
142
+ local entry name name_lc
143
+ for entry in "$parent"/*; do
144
+ [ ! -d "$entry" ] && continue
145
+ name=$(basename "$entry")
146
+ name_lc=$(_lpg_lower "$name")
147
+ # Skip the target itself.
148
+ [ "$name_lc" = "$target_lc" ] && continue
149
+ # Fixed-name whitelist (case-insensitive).
150
+ case " $fixed_lc " in
151
+ *" $name_lc "*) printf '%s\n' "$entry"; continue ;;
152
+ esac
153
+ # Pattern siblings against original (case-sensitive) basename.
154
+ case "$name" in
155
+ "$basename_target"-*|"$basename_target"_*|*-"$basename_target"|*_"$basename_target")
156
+ printf '%s\n' "$entry"
157
+ continue
158
+ ;;
159
+ esac
160
+ done
161
+ }
162
+
163
+ # Compute cache key (sha256 of sorted [mtime+path] for each found manifest).
164
+ # argv: each manifest path as a separate argument.
165
+ # stdout: hex sha256.
166
+ # Note: we can't both pipe paths AND heredoc the python script to the same
167
+ # stdin, so paths come in via argv.
168
+ _lpg_cache_key() {
169
+ python3 - "$@" <<'PYEOF' 2>/dev/null
170
+ import hashlib, os, sys
171
+ paths = sorted(set(sys.argv[1:]))
172
+ items = []
173
+ for p in paths:
174
+ try:
175
+ st = os.stat(p)
176
+ items.append(f"{st.st_mtime_ns}:{p}")
177
+ except OSError:
178
+ # Missing now -- treat as path-only so we still hash deterministically.
179
+ items.append(f"0:{p}")
180
+ h = hashlib.sha256()
181
+ for it in items:
182
+ h.update(it.encode('utf-8'))
183
+ h.update(b'\n')
184
+ print(h.hexdigest())
185
+ PYEOF
186
+ }
187
+
188
+ # Read cache. stdout: cached JSON if hit + valid; empty otherwise.
189
+ _lpg_cache_read() {
190
+ local target_dir="${1:-}"
191
+ local cache_key="${2:-}"
192
+ [ -z "$target_dir" ] || [ -z "$cache_key" ] && return 0
193
+ local cache_file="$target_dir/.loki/state/project-graph.json"
194
+ [ ! -f "$cache_file" ] && return 0
195
+ python3 - "$cache_file" "$cache_key" <<'PYEOF' 2>/dev/null
196
+ import json, sys
197
+ path, key = sys.argv[1], sys.argv[2]
198
+ try:
199
+ with open(path, 'r', encoding='utf-8') as f:
200
+ data = json.load(f)
201
+ except Exception:
202
+ sys.exit(0)
203
+ if not isinstance(data, dict):
204
+ sys.exit(0)
205
+ if data.get('cache_key') != key:
206
+ sys.exit(0)
207
+ print(json.dumps(data))
208
+ PYEOF
209
+ }
210
+
211
+ # Write cache.
212
+ _lpg_cache_write() {
213
+ local target_dir="${1:-}"
214
+ local result_json="${2:-}"
215
+ [ -z "$target_dir" ] || [ -z "$result_json" ] && return 0
216
+ local cache_dir="$target_dir/.loki/state"
217
+ mkdir -p "$cache_dir" 2>/dev/null || return 0
218
+ printf '%s' "$result_json" > "$cache_dir/project-graph.json" 2>/dev/null || true
219
+ }
220
+
221
+ # Append a line to the skip log (mismatched siblings, parse errors).
222
+ _lpg_log_skip() {
223
+ local target_dir="${1:-}"
224
+ local msg="${2:-}"
225
+ [ -z "$target_dir" ] || [ -z "$msg" ] && return 0
226
+ local log_dir="$target_dir/.loki/state"
227
+ mkdir -p "$log_dir" 2>/dev/null || return 0
228
+ local ts
229
+ ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "ts?")
230
+ printf '[%s] %s\n' "$ts" "$msg" >> "$log_dir/project-graph.log" 2>/dev/null || true
231
+ }
232
+
233
+ # Main discovery entry point.
234
+ # Always returns 0. On no graph, exports empty vars.
235
+ loki_project_graph_discover() {
236
+ local target_dir="${1:-}"
237
+ # Default exports (cleared even on early return).
238
+ export LOKI_PROJECT_GRAPH_ROOT=""
239
+ export LOKI_PROJECT_GRAPH_APP_ID=""
240
+ export LOKI_PROJECT_GRAPH_MEMBERS=""
241
+ export LOKI_PROJECT_GRAPH_SHARED_MEMORY_DIR=""
242
+
243
+ [ -z "$target_dir" ] && return 0
244
+ [ ! -d "$target_dir" ] && return 0
245
+
246
+ target_dir=$(_lpg_abs "$target_dir")
247
+ local basename_target
248
+ basename_target=$(basename "$target_dir")
249
+ local parent_dir
250
+ parent_dir=$(dirname "$target_dir")
251
+ [ "$parent_dir" = "$target_dir" ] && return 0
252
+
253
+ # Collect candidate manifests:
254
+ # (a) sibling roots, (b) target itself, (c) parent.
255
+ local candidates=()
256
+ local sib
257
+ while IFS= read -r sib; do
258
+ [ -z "$sib" ] && continue
259
+ candidates+=("$sib/.loki/app.json")
260
+ done < <(_lpg_walk_siblings "$target_dir" "$basename_target")
261
+ candidates+=("$target_dir/.loki/app.json")
262
+ candidates+=("$parent_dir/.loki/app.json")
263
+
264
+ # Keep only existing manifest paths.
265
+ local found=()
266
+ local c
267
+ for c in "${candidates[@]}"; do
268
+ [ -f "$c" ] && found+=("$c")
269
+ done
270
+
271
+ # Need at least one manifest to consider a graph.
272
+ if [ "${#found[@]}" -eq 0 ]; then
273
+ return 0
274
+ fi
275
+
276
+ # Fast cache path: NO python3 in the hot path. Cache is valid iff
277
+ # cache_file_mtime >= max(manifest_mtimes). sha256 of the path+mtime
278
+ # tuple is still computed on the SLOW path (cache write) for forensic
279
+ # debuggability via the on-disk JSON; the hot path skips that work.
280
+ # On macOS Darwin, python3 startup alone is ~25ms; eliminating it lets
281
+ # cache hits run in ~15-20ms, well under the architect's 50ms budget.
282
+ local cache_file="$target_dir/.loki/state/project-graph.json"
283
+ if [ -f "$cache_file" ]; then
284
+ local cache_mtime newest_manifest_mtime mt m
285
+ cache_mtime=$($__LPG_STAT_MTIME "$cache_file" 2>/dev/null)
286
+ newest_manifest_mtime=0
287
+ for m in "${found[@]}"; do
288
+ mt=$($__LPG_STAT_MTIME "$m" 2>/dev/null)
289
+ [ -z "$mt" ] && continue
290
+ # Compare as integers; both BSD and GNU stat emit whole seconds here.
291
+ if [ "$mt" -gt "$newest_manifest_mtime" ] 2>/dev/null; then
292
+ newest_manifest_mtime=$mt
293
+ fi
294
+ done
295
+ if [ -n "$cache_mtime" ] && [ "$cache_mtime" -ge "$newest_manifest_mtime" ] 2>/dev/null; then
296
+ # Shell-native JSON extraction. We control the writer (single line
297
+ # per key, no embedded quotes in app_id/root/members), so awk is
298
+ # safe and saves the python3 spawn.
299
+ local cache_root cache_app cache_members
300
+ cache_root=$(awk -F'"' '/"root":/ {print $4; exit}' "$cache_file" 2>/dev/null)
301
+ cache_app=$(awk -F'"' '/"app_id":/ {print $4; exit}' "$cache_file" 2>/dev/null)
302
+ # members is a multi-line JSON array of strings. Switch into
303
+ # "inside-members" mode at `"members": [`, collect each quoted
304
+ # entry on its own line, exit at the closing `]`. Result: a
305
+ # colon-separated list of absolute paths.
306
+ cache_members=$(awk -F'"' '
307
+ /"members":/ { inside=1; next }
308
+ inside && /\]/ { inside=0; exit }
309
+ inside && NF >= 3 {
310
+ if (out == "") { out = $2 } else { out = out ":" $2 }
311
+ }
312
+ END { print out }
313
+ ' "$cache_file" 2>/dev/null)
314
+ if [ -n "$cache_root" ] && [ -n "$cache_app" ]; then
315
+ LOKI_PROJECT_GRAPH_ROOT="$cache_root"
316
+ LOKI_PROJECT_GRAPH_APP_ID="$cache_app"
317
+ LOKI_PROJECT_GRAPH_MEMBERS="$cache_members"
318
+ export LOKI_PROJECT_GRAPH_ROOT LOKI_PROJECT_GRAPH_APP_ID LOKI_PROJECT_GRAPH_MEMBERS
319
+ return 0
320
+ fi
321
+ fi
322
+ fi
323
+
324
+ # Cache miss / stale -- compute cache_key for the eventual write.
325
+ local cache_key
326
+ cache_key=$(_lpg_cache_key "${found[@]}")
327
+
328
+ # Parse all manifests + cluster by app_id.
329
+ local target_app_id=""
330
+ local parent_app_id=""
331
+ local manifest app_id
332
+ declare -a parsed_paths=()
333
+ declare -a parsed_ids=()
334
+ for manifest in "${found[@]}"; do
335
+ app_id=$(_lpg_parse_app_json "$manifest")
336
+ if [ -z "$app_id" ]; then
337
+ _lpg_log_skip "$target_dir" "parse_failed: $manifest"
338
+ continue
339
+ fi
340
+ parsed_paths+=("$manifest")
341
+ parsed_ids+=("$app_id")
342
+ if [ "$manifest" = "$target_dir/.loki/app.json" ]; then
343
+ target_app_id="$app_id"
344
+ elif [ "$manifest" = "$parent_dir/.loki/app.json" ]; then
345
+ parent_app_id="$app_id"
346
+ fi
347
+ done
348
+
349
+ # Determine the authoritative app_id: prefer target -> parent -> majority of siblings.
350
+ local resolved_id=""
351
+ if [ -n "$target_app_id" ]; then
352
+ resolved_id="$target_app_id"
353
+ elif [ -n "$parent_app_id" ]; then
354
+ resolved_id="$parent_app_id"
355
+ else
356
+ # Majority vote across siblings.
357
+ if [ "${#parsed_ids[@]}" -gt 0 ]; then
358
+ resolved_id=$(printf '%s\n' "${parsed_ids[@]}" | sort | uniq -c | sort -rn | head -n1 | awk '{print $2}')
359
+ fi
360
+ fi
361
+
362
+ if [ -z "$resolved_id" ]; then
363
+ return 0
364
+ fi
365
+
366
+ # Build members list (all manifest dirs whose app_id matches resolved_id).
367
+ local i count member_dir
368
+ declare -a members=()
369
+ count=${#parsed_paths[@]}
370
+ for (( i=0; i<count; i++ )); do
371
+ if [ "${parsed_ids[$i]}" = "$resolved_id" ]; then
372
+ member_dir=$(dirname "$(dirname "${parsed_paths[$i]}")")
373
+ # Exclude the parent root from members (parent is the graph root, not a member).
374
+ if [ "$member_dir" != "$parent_dir" ]; then
375
+ members+=("$member_dir")
376
+ fi
377
+ else
378
+ _lpg_log_skip "$target_dir" "app_id_mismatch: ${parsed_paths[$i]} (got=${parsed_ids[$i]} want=$resolved_id)"
379
+ fi
380
+ done
381
+
382
+ # Honor explicit members[] from parent manifest if present (resolve to abs paths under parent).
383
+ local parent_manifest="$parent_dir/.loki/app.json"
384
+ if [ -f "$parent_manifest" ]; then
385
+ local parent_meta
386
+ parent_meta=$(_lpg_manifest_meta "$parent_manifest")
387
+ local explicit_members
388
+ explicit_members=$(python3 -c '
389
+ import json, os, sys
390
+ meta = json.loads(sys.argv[1] or "{}")
391
+ parent = sys.argv[2]
392
+ out = []
393
+ for m in meta.get("members", []):
394
+ if not isinstance(m, str):
395
+ continue
396
+ p = m if os.path.isabs(m) else os.path.join(parent, m)
397
+ if os.path.isdir(p):
398
+ out.append(os.path.realpath(p))
399
+ print(":".join(out))
400
+ ' "$parent_meta" "$parent_dir" 2>/dev/null)
401
+ # Bug fix (v7.5.28, found via real CLI smoke): when only the parent
402
+ # has .loki/app.json (no thin pointers in members), the sibling
403
+ # walker's discovered `members` is empty -- in that case the
404
+ # explicit members[] from the parent manifest IS the authoritative
405
+ # member list (no intersection to apply). When discovered members
406
+ # exist, fall back to the intersection-narrow semantics.
407
+ if [ -n "$explicit_members" ]; then
408
+ local em_arr=()
409
+ IFS=':' read -r -a em_arr <<<"$explicit_members"
410
+ if [ "${#members[@]}" -eq 0 ]; then
411
+ # No sibling-discovered members -- adopt explicit list verbatim.
412
+ members=("${em_arr[@]}")
413
+ else
414
+ # Sibling-discovered members exist -- intersect with explicit.
415
+ local final=()
416
+ local em m
417
+ for em in "${em_arr[@]}"; do
418
+ for m in "${members[@]}"; do
419
+ if [ "$m" = "$em" ]; then
420
+ final+=("$m")
421
+ break
422
+ fi
423
+ done
424
+ done
425
+ if [ "${#final[@]}" -gt 0 ]; then
426
+ members=("${final[@]}")
427
+ fi
428
+ fi
429
+ fi
430
+ fi
431
+
432
+ # Need at least 1 member to call it a graph.
433
+ if [ "${#members[@]}" -eq 0 ]; then
434
+ return 0
435
+ fi
436
+
437
+ # De-dupe + sort members.
438
+ local members_joined
439
+ members_joined=$(printf '%s\n' "${members[@]}" | awk '!seen[$0]++' | sort | paste -sd ':' -)
440
+
441
+ # Phase F: extract shared_memory_dir from parent manifest (if any) so the
442
+ # TS/Python side can honor it via LOKI_PROJECT_GRAPH_SHARED_MEMORY_DIR.
443
+ # Default empty = consumers use their own default ("$ROOT/.loki-shared/memory").
444
+ local shared_mem_dir=""
445
+ if [ -f "$parent_manifest" ]; then
446
+ shared_mem_dir=$(python3 -c '
447
+ import json, sys
448
+ try:
449
+ d = json.loads(sys.argv[1])
450
+ v = d.get("shared_memory_dir")
451
+ if isinstance(v, str):
452
+ print(v)
453
+ except Exception:
454
+ pass
455
+ ' "$(_lpg_manifest_meta "$parent_manifest")" 2>/dev/null)
456
+ fi
457
+
458
+ LOKI_PROJECT_GRAPH_ROOT="$parent_dir"
459
+ LOKI_PROJECT_GRAPH_APP_ID="$resolved_id"
460
+ LOKI_PROJECT_GRAPH_MEMBERS="$members_joined"
461
+ LOKI_PROJECT_GRAPH_SHARED_MEMORY_DIR="$shared_mem_dir"
462
+ export LOKI_PROJECT_GRAPH_ROOT LOKI_PROJECT_GRAPH_APP_ID LOKI_PROJECT_GRAPH_MEMBERS LOKI_PROJECT_GRAPH_SHARED_MEMORY_DIR
463
+
464
+ # Persist cache. Write as multi-line JSON, ONE KEY PER LINE, so the
465
+ # hot-path awk extraction (`/"root":/ {print $4}`) is unambiguous --
466
+ # awk splits each line by `"` and the value is always field 4 of the
467
+ # line that contains the key. This format is also valid JSON.
468
+ if [ -n "$cache_key" ]; then
469
+ local result_json
470
+ result_json=$(python3 -c '
471
+ import json, sys
472
+ print(json.dumps({
473
+ "cache_key": sys.argv[1],
474
+ "root": sys.argv[2],
475
+ "app_id": sys.argv[3],
476
+ "members": sys.argv[4].split(":") if sys.argv[4] else [],
477
+ }, indent=2))
478
+ ' "$cache_key" "$parent_dir" "$resolved_id" "$members_joined" 2>/dev/null)
479
+ _lpg_cache_write "$target_dir" "$result_json"
480
+ fi
481
+
482
+ return 0
483
+ }
484
+
485
+ # Read CLAUDE.md from a single path, truncated to per-layer cap.
486
+ # stdout: file contents (possibly truncated); empty if file missing or empty.
487
+ _lpg_read_layer() {
488
+ local path="${1:-}"
489
+ [ -z "$path" ] || [ ! -f "$path" ] && return 0
490
+ python3 - "$path" "$__LPG_PER_LAYER_CAP" <<'PYEOF' 2>/dev/null
491
+ import sys
492
+ path, cap = sys.argv[1], int(sys.argv[2])
493
+ try:
494
+ with open(path, 'r', encoding='utf-8', errors='replace') as f:
495
+ text = f.read()
496
+ except Exception:
497
+ sys.exit(0)
498
+ if len(text.encode('utf-8')) > cap:
499
+ # Truncate by bytes then re-decode safely.
500
+ enc = text.encode('utf-8')[:cap]
501
+ try:
502
+ text = enc.decode('utf-8', errors='ignore')
503
+ except Exception:
504
+ text = ''
505
+ text = text.rstrip() + '\n<!-- truncated -->\n'
506
+ sys.stdout.write(text)
507
+ PYEOF
508
+ }
509
+
510
+ # Phase G: walk upward from <target_dir> looking for the nearest `.git/`
511
+ # ancestor. Caps the walk at 8 levels and stops at $HOME. Echoes the
512
+ # absolute path to the git-root directory (the one whose child `.git`
513
+ # exists), or empty when no ancestor matches.
514
+ #
515
+ # Honors $HOME stop: we never cross out of the user's home tree. This
516
+ # keeps subdir activation scoped to user repos and avoids climbing to /
517
+ # on machines where $HOME != /.
518
+ _lpg_find_git_root() {
519
+ local cur="${1:-}"
520
+ [ -z "$cur" ] && return 0
521
+ [ ! -d "$cur" ] && return 0
522
+ cur=$(_lpg_abs "$cur")
523
+ local home_abs
524
+ home_abs="${HOME:-}"
525
+ [ -n "$home_abs" ] && home_abs=$(_lpg_abs "$home_abs")
526
+
527
+ local depth=0
528
+ while [ "$depth" -lt 8 ]; do
529
+ if [ -d "$cur/.git" ]; then
530
+ printf '%s' "$cur"
531
+ return 0
532
+ fi
533
+ # Stop at $HOME boundary.
534
+ if [ -n "$home_abs" ] && [ "$cur" = "$home_abs" ]; then
535
+ return 0
536
+ fi
537
+ local parent
538
+ parent=$(dirname "$cur")
539
+ if [ "$parent" = "$cur" ] || [ "$parent" = "/" ]; then
540
+ return 0
541
+ fi
542
+ cur="$parent"
543
+ depth=$((depth + 1))
544
+ done
545
+ return 0
546
+ }
547
+
548
+ # Emit a layered CLAUDE.md block.
549
+ # Backward compat: empty stdout when LOKI_PROJECT_GRAPH_ROOT is unset AND
550
+ # subdir-mode is not eligible. Subdir-mode activates when (a)
551
+ # LOKI_PROJECT_GRAPH_ROOT is set OR (b) TARGET_DIR/LOKI_TARGET_DIR is
552
+ # explicitly set AND it sits inside a `.git/` ancestor (cap 8 levels,
553
+ # stop at $HOME). The implicit `$(pwd)` fallback NEVER activates
554
+ # subdir-mode, which preserves the existing "empty out when env unset"
555
+ # contract used by case 1 of tests/test-claude-md-walker.sh.
556
+ #
557
+ # Layer order: parent -> members -> subdir(root-to-leaf, deepest last)
558
+ # -> scope. All paths are deduped via a tracked set of absolute paths.
559
+ # Honors __LPG_TOTAL_CAP across all layers (stops at layer boundary).
560
+ load_app_graph_context() {
561
+ local root="${LOKI_PROJECT_GRAPH_ROOT:-}"
562
+
563
+ # Resolve target dir. We must distinguish an explicit caller-provided
564
+ # TARGET_DIR/LOKI_TARGET_DIR from the implicit $(pwd) fallback so that
565
+ # subdir-mode never activates on the implicit case (backward compat
566
+ # for callers that run the walker without setting either env var).
567
+ local target_dir target_explicit=0
568
+ if [ -n "${TARGET_DIR:-}" ]; then
569
+ target_dir="$TARGET_DIR"
570
+ target_explicit=1
571
+ elif [ -n "${LOKI_TARGET_DIR:-}" ]; then
572
+ target_dir="$LOKI_TARGET_DIR"
573
+ target_explicit=1
574
+ else
575
+ target_dir="$(pwd)"
576
+ fi
577
+ target_dir=$(_lpg_abs "$target_dir")
578
+
579
+ # Decide if subdir-mode is eligible. Eligibility requires an explicit
580
+ # target dir AND a `.git/` ancestor inside the cap. (Eligibility is
581
+ # also implied whenever LOKI_PROJECT_GRAPH_ROOT is set, since that
582
+ # path was already validated by the discovery pass.)
583
+ local git_root=""
584
+ if [ "$target_explicit" = "1" ]; then
585
+ git_root=$(_lpg_find_git_root "$target_dir")
586
+ fi
587
+
588
+ # Nothing to emit if neither project-graph nor subdir-mode is active.
589
+ if [ -z "$root" ] && [ -z "$git_root" ]; then
590
+ return 0
591
+ fi
592
+
593
+ local members_csv="${LOKI_PROJECT_GRAPH_MEMBERS:-}"
594
+ local members_arr=()
595
+ if [ -n "$members_csv" ]; then
596
+ IFS=':' read -r -a members_arr <<<"$members_csv"
597
+ fi
598
+
599
+ local total_bytes=0
600
+ local out=""
601
+ # Dedupe set: colon-delimited list of absolute paths already appended.
602
+ # Membership check uses the `:path:` substring pattern so we never get
603
+ # false hits from path-prefix collisions.
604
+ local seen_paths=":"
605
+
606
+ _append_layer() {
607
+ local kind="$1"
608
+ local p="$2"
609
+ [ ! -f "$p" ] && return 0
610
+ # Dedupe: skip if this absolute path was already emitted.
611
+ case "$seen_paths" in
612
+ *":$p:"*) return 0 ;;
613
+ esac
614
+ local content
615
+ content=$(_lpg_read_layer "$p")
616
+ [ -z "$content" ] && return 0
617
+ local block
618
+ block=$(printf '<!-- LOKI_LAYER:%s path=%s -->\n%s\n<!-- /LOKI_LAYER -->\n' "$kind" "$p" "$content")
619
+ local block_bytes
620
+ block_bytes=$(printf '%s' "$block" | wc -c | tr -d ' ')
621
+ if [ $((total_bytes + block_bytes)) -gt "$__LPG_TOTAL_CAP" ]; then
622
+ # Stop at section boundary; do not split a layer mid-content.
623
+ return 1
624
+ fi
625
+ if [ -z "$out" ]; then
626
+ out="$block"
627
+ else
628
+ out="${out}${block}"
629
+ fi
630
+ total_bytes=$((total_bytes + block_bytes))
631
+ seen_paths="${seen_paths}${p}:"
632
+ return 0
633
+ }
634
+
635
+ # Parent layer first.
636
+ if [ -n "$root" ]; then
637
+ _append_layer parent "$root/CLAUDE.md" || { printf '%s' "$out"; return 0; }
638
+ fi
639
+
640
+ # Member layers (skip the scope member -- we add it as scope below).
641
+ local m
642
+ for m in "${members_arr[@]}"; do
643
+ [ -z "$m" ] && continue
644
+ if [ "$m" = "$target_dir" ]; then
645
+ continue
646
+ fi
647
+ _append_layer member "$m/CLAUDE.md" || { printf '%s' "$out"; return 0; }
648
+ done
649
+
650
+ # Subdir layers: ancestors of target_dir up to (and including) git_root,
651
+ # emitted root-to-leaf so the deepest layer comes last. The scope layer
652
+ # is emitted separately below, so we exclude target_dir itself from the
653
+ # subdir walk.
654
+ if [ -n "$git_root" ]; then
655
+ # Collect ancestors from target_dir up to git_root (exclusive of
656
+ # target_dir, inclusive of git_root). Cap at 8 hops as a safety net
657
+ # in case the inputs are pathological.
658
+ local subdir_chain=()
659
+ local cur="$target_dir"
660
+ local hops=0
661
+ while [ "$hops" -lt 8 ]; do
662
+ local parent
663
+ parent=$(dirname "$cur")
664
+ if [ "$parent" = "$cur" ] || [ "$parent" = "/" ]; then
665
+ break
666
+ fi
667
+ cur="$parent"
668
+ subdir_chain+=("$cur")
669
+ if [ "$cur" = "$git_root" ]; then
670
+ break
671
+ fi
672
+ hops=$((hops + 1))
673
+ done
674
+ # subdir_chain is leaf-to-root order; reverse so we emit root-to-leaf.
675
+ local i count=${#subdir_chain[@]}
676
+ for (( i = count - 1; i >= 0; i-- )); do
677
+ _append_layer subdir "${subdir_chain[$i]}/CLAUDE.md" || { printf '%s' "$out"; return 0; }
678
+ done
679
+ fi
680
+
681
+ # Scope layer (target dir).
682
+ _append_layer scope "$target_dir/CLAUDE.md" || { printf '%s' "$out"; return 0; }
683
+
684
+ printf '%s' "$out"
685
+ }