loki-mode 7.5.17 → 7.5.27
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 +10 -9
- package/SKILL.md +14 -14
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +26 -3
- package/autonomy/lib/claude-flags.sh +132 -0
- package/autonomy/lib/mcp-config.sh +160 -0
- package/autonomy/lib/project-graph.sh +675 -0
- package/autonomy/lib/voter-agents.sh +356 -0
- package/autonomy/loki +61 -96
- package/autonomy/run.sh +95 -186
- package/bin/loki +10 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/requirements.txt +13 -8
- package/dashboard/server.py +33 -15
- package/dashboard/static/index.html +298 -299
- package/docs/INSTALLATION.md +54 -21
- package/docs/retrospectives/v7.5.15-fleet-postmortem.md +325 -0
- package/docs/retrospectives/v7.5.15-honesty-audit.md +136 -0
- package/docs/retrospectives/v7.5.15-llm-failure-modes.md +49 -0
- package/loki-ts/data/finding-schema.json +74 -0
- package/loki-ts/data/model-pricing.json +12 -0
- package/loki-ts/dist/loki.js +109 -108
- package/mcp/__init__.py +1 -1
- package/mcp/lsp_proxy.py +713 -0
- package/mcp/requirements.txt +9 -3
- package/mcp/tests/__init__.py +0 -0
- package/mcp/tests/test_lsp_proxy.py +377 -0
- package/memory/app_graph.py +153 -0
- package/memory/storage.py +6 -1
- package/memory/tests/test_app_graph.py +134 -0
- package/package.json +4 -3
- package/providers/claude.sh +115 -4
- package/providers/codex.sh +2 -2
- package/providers/loader.sh +4 -4
- package/providers/model_catalog.json +0 -9
- package/providers/models.sh +1 -2
- package/references/multi-provider.md +26 -35
- package/references/prompt-repetition.md +1 -1
- package/references/quality-control.md +1 -1
- package/skills/00-index.md +3 -3
- package/skills/model-selection.md +11 -14
- package/skills/providers.md +17 -57
- package/skills/quality-gates.md +2 -2
- package/skills/troubleshooting.md +1 -1
- package/src/integrations/github/action-handler.js +3 -2
- package/src/protocols/tools/start-project.js +1 -1
- package/providers/gemini.sh +0 -343
|
@@ -0,0 +1,675 @@
|
|
|
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
|
+
# If explicit members declared, intersect with discovered members.
|
|
402
|
+
if [ -n "$explicit_members" ]; then
|
|
403
|
+
local final=()
|
|
404
|
+
local em
|
|
405
|
+
IFS=':' read -r -a em_arr <<<"$explicit_members"
|
|
406
|
+
for em in "${em_arr[@]}"; do
|
|
407
|
+
local m
|
|
408
|
+
for m in "${members[@]}"; do
|
|
409
|
+
if [ "$m" = "$em" ]; then
|
|
410
|
+
final+=("$m")
|
|
411
|
+
break
|
|
412
|
+
fi
|
|
413
|
+
done
|
|
414
|
+
done
|
|
415
|
+
# If explicit list narrows nothing, fall back to discovered.
|
|
416
|
+
if [ "${#final[@]}" -gt 0 ]; then
|
|
417
|
+
members=("${final[@]}")
|
|
418
|
+
fi
|
|
419
|
+
fi
|
|
420
|
+
fi
|
|
421
|
+
|
|
422
|
+
# Need at least 1 member to call it a graph.
|
|
423
|
+
if [ "${#members[@]}" -eq 0 ]; then
|
|
424
|
+
return 0
|
|
425
|
+
fi
|
|
426
|
+
|
|
427
|
+
# De-dupe + sort members.
|
|
428
|
+
local members_joined
|
|
429
|
+
members_joined=$(printf '%s\n' "${members[@]}" | awk '!seen[$0]++' | sort | paste -sd ':' -)
|
|
430
|
+
|
|
431
|
+
# Phase F: extract shared_memory_dir from parent manifest (if any) so the
|
|
432
|
+
# TS/Python side can honor it via LOKI_PROJECT_GRAPH_SHARED_MEMORY_DIR.
|
|
433
|
+
# Default empty = consumers use their own default ("$ROOT/.loki-shared/memory").
|
|
434
|
+
local shared_mem_dir=""
|
|
435
|
+
if [ -f "$parent_manifest" ]; then
|
|
436
|
+
shared_mem_dir=$(python3 -c '
|
|
437
|
+
import json, sys
|
|
438
|
+
try:
|
|
439
|
+
d = json.loads(sys.argv[1])
|
|
440
|
+
v = d.get("shared_memory_dir")
|
|
441
|
+
if isinstance(v, str):
|
|
442
|
+
print(v)
|
|
443
|
+
except Exception:
|
|
444
|
+
pass
|
|
445
|
+
' "$(_lpg_manifest_meta "$parent_manifest")" 2>/dev/null)
|
|
446
|
+
fi
|
|
447
|
+
|
|
448
|
+
LOKI_PROJECT_GRAPH_ROOT="$parent_dir"
|
|
449
|
+
LOKI_PROJECT_GRAPH_APP_ID="$resolved_id"
|
|
450
|
+
LOKI_PROJECT_GRAPH_MEMBERS="$members_joined"
|
|
451
|
+
LOKI_PROJECT_GRAPH_SHARED_MEMORY_DIR="$shared_mem_dir"
|
|
452
|
+
export LOKI_PROJECT_GRAPH_ROOT LOKI_PROJECT_GRAPH_APP_ID LOKI_PROJECT_GRAPH_MEMBERS LOKI_PROJECT_GRAPH_SHARED_MEMORY_DIR
|
|
453
|
+
|
|
454
|
+
# Persist cache. Write as multi-line JSON, ONE KEY PER LINE, so the
|
|
455
|
+
# hot-path awk extraction (`/"root":/ {print $4}`) is unambiguous --
|
|
456
|
+
# awk splits each line by `"` and the value is always field 4 of the
|
|
457
|
+
# line that contains the key. This format is also valid JSON.
|
|
458
|
+
if [ -n "$cache_key" ]; then
|
|
459
|
+
local result_json
|
|
460
|
+
result_json=$(python3 -c '
|
|
461
|
+
import json, sys
|
|
462
|
+
print(json.dumps({
|
|
463
|
+
"cache_key": sys.argv[1],
|
|
464
|
+
"root": sys.argv[2],
|
|
465
|
+
"app_id": sys.argv[3],
|
|
466
|
+
"members": sys.argv[4].split(":") if sys.argv[4] else [],
|
|
467
|
+
}, indent=2))
|
|
468
|
+
' "$cache_key" "$parent_dir" "$resolved_id" "$members_joined" 2>/dev/null)
|
|
469
|
+
_lpg_cache_write "$target_dir" "$result_json"
|
|
470
|
+
fi
|
|
471
|
+
|
|
472
|
+
return 0
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
# Read CLAUDE.md from a single path, truncated to per-layer cap.
|
|
476
|
+
# stdout: file contents (possibly truncated); empty if file missing or empty.
|
|
477
|
+
_lpg_read_layer() {
|
|
478
|
+
local path="${1:-}"
|
|
479
|
+
[ -z "$path" ] || [ ! -f "$path" ] && return 0
|
|
480
|
+
python3 - "$path" "$__LPG_PER_LAYER_CAP" <<'PYEOF' 2>/dev/null
|
|
481
|
+
import sys
|
|
482
|
+
path, cap = sys.argv[1], int(sys.argv[2])
|
|
483
|
+
try:
|
|
484
|
+
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
|
485
|
+
text = f.read()
|
|
486
|
+
except Exception:
|
|
487
|
+
sys.exit(0)
|
|
488
|
+
if len(text.encode('utf-8')) > cap:
|
|
489
|
+
# Truncate by bytes then re-decode safely.
|
|
490
|
+
enc = text.encode('utf-8')[:cap]
|
|
491
|
+
try:
|
|
492
|
+
text = enc.decode('utf-8', errors='ignore')
|
|
493
|
+
except Exception:
|
|
494
|
+
text = ''
|
|
495
|
+
text = text.rstrip() + '\n<!-- truncated -->\n'
|
|
496
|
+
sys.stdout.write(text)
|
|
497
|
+
PYEOF
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
# Phase G: walk upward from <target_dir> looking for the nearest `.git/`
|
|
501
|
+
# ancestor. Caps the walk at 8 levels and stops at $HOME. Echoes the
|
|
502
|
+
# absolute path to the git-root directory (the one whose child `.git`
|
|
503
|
+
# exists), or empty when no ancestor matches.
|
|
504
|
+
#
|
|
505
|
+
# Honors $HOME stop: we never cross out of the user's home tree. This
|
|
506
|
+
# keeps subdir activation scoped to user repos and avoids climbing to /
|
|
507
|
+
# on machines where $HOME != /.
|
|
508
|
+
_lpg_find_git_root() {
|
|
509
|
+
local cur="${1:-}"
|
|
510
|
+
[ -z "$cur" ] && return 0
|
|
511
|
+
[ ! -d "$cur" ] && return 0
|
|
512
|
+
cur=$(_lpg_abs "$cur")
|
|
513
|
+
local home_abs
|
|
514
|
+
home_abs="${HOME:-}"
|
|
515
|
+
[ -n "$home_abs" ] && home_abs=$(_lpg_abs "$home_abs")
|
|
516
|
+
|
|
517
|
+
local depth=0
|
|
518
|
+
while [ "$depth" -lt 8 ]; do
|
|
519
|
+
if [ -d "$cur/.git" ]; then
|
|
520
|
+
printf '%s' "$cur"
|
|
521
|
+
return 0
|
|
522
|
+
fi
|
|
523
|
+
# Stop at $HOME boundary.
|
|
524
|
+
if [ -n "$home_abs" ] && [ "$cur" = "$home_abs" ]; then
|
|
525
|
+
return 0
|
|
526
|
+
fi
|
|
527
|
+
local parent
|
|
528
|
+
parent=$(dirname "$cur")
|
|
529
|
+
if [ "$parent" = "$cur" ] || [ "$parent" = "/" ]; then
|
|
530
|
+
return 0
|
|
531
|
+
fi
|
|
532
|
+
cur="$parent"
|
|
533
|
+
depth=$((depth + 1))
|
|
534
|
+
done
|
|
535
|
+
return 0
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
# Emit a layered CLAUDE.md block.
|
|
539
|
+
# Backward compat: empty stdout when LOKI_PROJECT_GRAPH_ROOT is unset AND
|
|
540
|
+
# subdir-mode is not eligible. Subdir-mode activates when (a)
|
|
541
|
+
# LOKI_PROJECT_GRAPH_ROOT is set OR (b) TARGET_DIR/LOKI_TARGET_DIR is
|
|
542
|
+
# explicitly set AND it sits inside a `.git/` ancestor (cap 8 levels,
|
|
543
|
+
# stop at $HOME). The implicit `$(pwd)` fallback NEVER activates
|
|
544
|
+
# subdir-mode, which preserves the existing "empty out when env unset"
|
|
545
|
+
# contract used by case 1 of tests/test-claude-md-walker.sh.
|
|
546
|
+
#
|
|
547
|
+
# Layer order: parent -> members -> subdir(root-to-leaf, deepest last)
|
|
548
|
+
# -> scope. All paths are deduped via a tracked set of absolute paths.
|
|
549
|
+
# Honors __LPG_TOTAL_CAP across all layers (stops at layer boundary).
|
|
550
|
+
load_app_graph_context() {
|
|
551
|
+
local root="${LOKI_PROJECT_GRAPH_ROOT:-}"
|
|
552
|
+
|
|
553
|
+
# Resolve target dir. We must distinguish an explicit caller-provided
|
|
554
|
+
# TARGET_DIR/LOKI_TARGET_DIR from the implicit $(pwd) fallback so that
|
|
555
|
+
# subdir-mode never activates on the implicit case (backward compat
|
|
556
|
+
# for callers that run the walker without setting either env var).
|
|
557
|
+
local target_dir target_explicit=0
|
|
558
|
+
if [ -n "${TARGET_DIR:-}" ]; then
|
|
559
|
+
target_dir="$TARGET_DIR"
|
|
560
|
+
target_explicit=1
|
|
561
|
+
elif [ -n "${LOKI_TARGET_DIR:-}" ]; then
|
|
562
|
+
target_dir="$LOKI_TARGET_DIR"
|
|
563
|
+
target_explicit=1
|
|
564
|
+
else
|
|
565
|
+
target_dir="$(pwd)"
|
|
566
|
+
fi
|
|
567
|
+
target_dir=$(_lpg_abs "$target_dir")
|
|
568
|
+
|
|
569
|
+
# Decide if subdir-mode is eligible. Eligibility requires an explicit
|
|
570
|
+
# target dir AND a `.git/` ancestor inside the cap. (Eligibility is
|
|
571
|
+
# also implied whenever LOKI_PROJECT_GRAPH_ROOT is set, since that
|
|
572
|
+
# path was already validated by the discovery pass.)
|
|
573
|
+
local git_root=""
|
|
574
|
+
if [ "$target_explicit" = "1" ]; then
|
|
575
|
+
git_root=$(_lpg_find_git_root "$target_dir")
|
|
576
|
+
fi
|
|
577
|
+
|
|
578
|
+
# Nothing to emit if neither project-graph nor subdir-mode is active.
|
|
579
|
+
if [ -z "$root" ] && [ -z "$git_root" ]; then
|
|
580
|
+
return 0
|
|
581
|
+
fi
|
|
582
|
+
|
|
583
|
+
local members_csv="${LOKI_PROJECT_GRAPH_MEMBERS:-}"
|
|
584
|
+
local members_arr=()
|
|
585
|
+
if [ -n "$members_csv" ]; then
|
|
586
|
+
IFS=':' read -r -a members_arr <<<"$members_csv"
|
|
587
|
+
fi
|
|
588
|
+
|
|
589
|
+
local total_bytes=0
|
|
590
|
+
local out=""
|
|
591
|
+
# Dedupe set: colon-delimited list of absolute paths already appended.
|
|
592
|
+
# Membership check uses the `:path:` substring pattern so we never get
|
|
593
|
+
# false hits from path-prefix collisions.
|
|
594
|
+
local seen_paths=":"
|
|
595
|
+
|
|
596
|
+
_append_layer() {
|
|
597
|
+
local kind="$1"
|
|
598
|
+
local p="$2"
|
|
599
|
+
[ ! -f "$p" ] && return 0
|
|
600
|
+
# Dedupe: skip if this absolute path was already emitted.
|
|
601
|
+
case "$seen_paths" in
|
|
602
|
+
*":$p:"*) return 0 ;;
|
|
603
|
+
esac
|
|
604
|
+
local content
|
|
605
|
+
content=$(_lpg_read_layer "$p")
|
|
606
|
+
[ -z "$content" ] && return 0
|
|
607
|
+
local block
|
|
608
|
+
block=$(printf '<!-- LOKI_LAYER:%s path=%s -->\n%s\n<!-- /LOKI_LAYER -->\n' "$kind" "$p" "$content")
|
|
609
|
+
local block_bytes
|
|
610
|
+
block_bytes=$(printf '%s' "$block" | wc -c | tr -d ' ')
|
|
611
|
+
if [ $((total_bytes + block_bytes)) -gt "$__LPG_TOTAL_CAP" ]; then
|
|
612
|
+
# Stop at section boundary; do not split a layer mid-content.
|
|
613
|
+
return 1
|
|
614
|
+
fi
|
|
615
|
+
if [ -z "$out" ]; then
|
|
616
|
+
out="$block"
|
|
617
|
+
else
|
|
618
|
+
out="${out}${block}"
|
|
619
|
+
fi
|
|
620
|
+
total_bytes=$((total_bytes + block_bytes))
|
|
621
|
+
seen_paths="${seen_paths}${p}:"
|
|
622
|
+
return 0
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
# Parent layer first.
|
|
626
|
+
if [ -n "$root" ]; then
|
|
627
|
+
_append_layer parent "$root/CLAUDE.md" || { printf '%s' "$out"; return 0; }
|
|
628
|
+
fi
|
|
629
|
+
|
|
630
|
+
# Member layers (skip the scope member -- we add it as scope below).
|
|
631
|
+
local m
|
|
632
|
+
for m in "${members_arr[@]}"; do
|
|
633
|
+
[ -z "$m" ] && continue
|
|
634
|
+
if [ "$m" = "$target_dir" ]; then
|
|
635
|
+
continue
|
|
636
|
+
fi
|
|
637
|
+
_append_layer member "$m/CLAUDE.md" || { printf '%s' "$out"; return 0; }
|
|
638
|
+
done
|
|
639
|
+
|
|
640
|
+
# Subdir layers: ancestors of target_dir up to (and including) git_root,
|
|
641
|
+
# emitted root-to-leaf so the deepest layer comes last. The scope layer
|
|
642
|
+
# is emitted separately below, so we exclude target_dir itself from the
|
|
643
|
+
# subdir walk.
|
|
644
|
+
if [ -n "$git_root" ]; then
|
|
645
|
+
# Collect ancestors from target_dir up to git_root (exclusive of
|
|
646
|
+
# target_dir, inclusive of git_root). Cap at 8 hops as a safety net
|
|
647
|
+
# in case the inputs are pathological.
|
|
648
|
+
local subdir_chain=()
|
|
649
|
+
local cur="$target_dir"
|
|
650
|
+
local hops=0
|
|
651
|
+
while [ "$hops" -lt 8 ]; do
|
|
652
|
+
local parent
|
|
653
|
+
parent=$(dirname "$cur")
|
|
654
|
+
if [ "$parent" = "$cur" ] || [ "$parent" = "/" ]; then
|
|
655
|
+
break
|
|
656
|
+
fi
|
|
657
|
+
cur="$parent"
|
|
658
|
+
subdir_chain+=("$cur")
|
|
659
|
+
if [ "$cur" = "$git_root" ]; then
|
|
660
|
+
break
|
|
661
|
+
fi
|
|
662
|
+
hops=$((hops + 1))
|
|
663
|
+
done
|
|
664
|
+
# subdir_chain is leaf-to-root order; reverse so we emit root-to-leaf.
|
|
665
|
+
local i count=${#subdir_chain[@]}
|
|
666
|
+
for (( i = count - 1; i >= 0; i-- )); do
|
|
667
|
+
_append_layer subdir "${subdir_chain[$i]}/CLAUDE.md" || { printf '%s' "$out"; return 0; }
|
|
668
|
+
done
|
|
669
|
+
fi
|
|
670
|
+
|
|
671
|
+
# Scope layer (target dir).
|
|
672
|
+
_append_layer scope "$target_dir/CLAUDE.md" || { printf '%s' "$out"; return 0; }
|
|
673
|
+
|
|
674
|
+
printf '%s' "$out"
|
|
675
|
+
}
|