loki-mode 7.88.0 → 7.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/crash.sh +57 -30
- package/autonomy/lib/own-render.py +78 -8
- package/autonomy/lib/proof-generator.py +27 -1
- package/autonomy/lib/wiki-generator.py +192 -4
- package/autonomy/loki +123 -36
- package/autonomy/run.sh +395 -34
- package/autonomy/spec-interrogation.sh +51 -5
- package/autonomy/telemetry.sh +89 -12
- package/bin/loki +89 -8
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +213 -0
- package/dashboard/static/assets/mermaid.min.js +2030 -0
- package/dashboard/static/index.html +314 -182
- package/dashboard/telemetry.py +62 -15
- package/docs/INSTALLATION.md +2 -2
- package/docs/PRIVACY.md +65 -38
- package/loki-ts/dist/loki.js +249 -243
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
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.
|
|
6
|
+
# Loki Mode v7.89.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.
|
|
411
|
+
**v7.89.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.89.0
|
package/autonomy/crash.sh
CHANGED
|
@@ -39,10 +39,46 @@ _LOKI_CRASH_CAPTURE_PY="${_LOKI_CRASH_DIR}/lib/crash_capture.py"
|
|
|
39
39
|
# its precedence MUST be mirrored by _is_enabled in dashboard/telemetry.py and
|
|
40
40
|
# _loki_telemetry_enabled in autonomy/telemetry.sh.
|
|
41
41
|
#
|
|
42
|
-
# Precedence
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
42
|
+
# Precedence (MUST mirror _loki_telemetry_enabled in autonomy/telemetry.sh and
|
|
43
|
+
# _is_enabled in dashboard/telemetry.py):
|
|
44
|
+
# 1. Any opt-out flag present -> OFF (hard kill, always wins)
|
|
45
|
+
# 2. Else explicit opt-in present -> ON (force-on, even in CI/enterprise)
|
|
46
|
+
# 3. Else enterprise/CI/air-gapped -> OFF (auto-off, safe out of the box)
|
|
47
|
+
# 4. Else (individual default) -> ON (anonymous diagnostics, disclosed once)
|
|
48
|
+
|
|
49
|
+
# _loki_collection_auto_off: enterprise / CI / air-gapped / non-interactive
|
|
50
|
+
# detection. Defined here too (not only telemetry.sh) because autonomy/loki sources
|
|
51
|
+
# crash.sh but not telemetry.sh. Guarded so a single definition wins if both load.
|
|
52
|
+
# This list MUST stay in sync with _loki_telemetry_auto_off (telemetry.sh) and
|
|
53
|
+
# dashboard/telemetry.py.
|
|
54
|
+
if ! declare -f _loki_collection_auto_off >/dev/null 2>&1; then
|
|
55
|
+
_loki_collection_auto_off() {
|
|
56
|
+
[ "${CI:-}" = "true" ] && return 0
|
|
57
|
+
[ -n "${GITHUB_ACTIONS:-}" ] && return 0
|
|
58
|
+
[ -n "${GITLAB_CI:-}" ] && return 0
|
|
59
|
+
[ -n "${BUILDKITE:-}" ] && return 0
|
|
60
|
+
[ -n "${JENKINS_URL:-}" ] && return 0
|
|
61
|
+
[ -n "${TEAMCITY_VERSION:-}" ] && return 0
|
|
62
|
+
[ "${CONTINUOUS_INTEGRATION:-}" = "true" ] && return 0
|
|
63
|
+
[ "${LOKI_ENTERPRISE:-}" = "true" ] && return 0
|
|
64
|
+
[ "${LOKI_AIRGAP:-}" = "true" ] && return 0
|
|
65
|
+
# Non-interactive detection (council cH_r1 AC2). MUST match
|
|
66
|
+
# _loki_telemetry_auto_off (telemetry.sh) and _auto_off (dashboard/telemetry.py):
|
|
67
|
+
# trust the explicit LOKI_TTY_INTERACTIVE signal resolved once at the entry
|
|
68
|
+
# point; fall back to a live `-t` probe only when the signal is unset (isolated
|
|
69
|
+
# unit test that bypassed the entry point). A fresh `-t` probe in an
|
|
70
|
+
# FD-detached subshell would wrongly auto-off a real interactive user.
|
|
71
|
+
if [ -n "${LOKI_TTY_INTERACTIVE:-}" ]; then
|
|
72
|
+
[ "${LOKI_TTY_INTERACTIVE}" = "1" ] && return 1
|
|
73
|
+
return 0
|
|
74
|
+
fi
|
|
75
|
+
if [ ! -t 1 ] && [ ! -t 0 ]; then
|
|
76
|
+
return 0
|
|
77
|
+
fi
|
|
78
|
+
return 1
|
|
79
|
+
}
|
|
80
|
+
fi
|
|
81
|
+
|
|
46
82
|
loki_collection_enabled() {
|
|
47
83
|
local telem_lower
|
|
48
84
|
telem_lower="$(printf '%s' "${LOKI_TELEMETRY:-}" | tr '[:upper:]' '[:lower:]')"
|
|
@@ -59,7 +95,7 @@ loki_collection_enabled() {
|
|
|
59
95
|
return 1
|
|
60
96
|
fi
|
|
61
97
|
|
|
62
|
-
# --- 2.
|
|
98
|
+
# --- 2. Explicit opt-in forces ON (overrides the enterprise/CI auto-off) ---
|
|
63
99
|
# Env: LOKI_TELEMETRY=on (case-insensitive, exact word "on").
|
|
64
100
|
[ "$telem_lower" = "on" ] && return 0
|
|
65
101
|
# Persistent opt-in in ~/.loki/config (written by: loki telemetry on).
|
|
@@ -67,8 +103,11 @@ loki_collection_enabled() {
|
|
|
67
103
|
return 0
|
|
68
104
|
fi
|
|
69
105
|
|
|
70
|
-
# --- 3.
|
|
71
|
-
return 1
|
|
106
|
+
# --- 3. Enterprise / CI / air-gapped: auto-off (safe out of the box) ---
|
|
107
|
+
_loki_collection_auto_off && return 1
|
|
108
|
+
|
|
109
|
+
# --- 4. Individual interactive default: ON (anonymous diagnostics) ---
|
|
110
|
+
return 0
|
|
72
111
|
}
|
|
73
112
|
|
|
74
113
|
# _loki_crash_python: resolve a python3 interpreter, or return 1 if absent.
|
|
@@ -156,35 +195,23 @@ loki_crash_friction() {
|
|
|
156
195
|
return 0
|
|
157
196
|
}
|
|
158
197
|
|
|
159
|
-
# loki_show_disclosure_once:
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
198
|
+
# loki_show_disclosure_once: intentionally a SILENT no-op.
|
|
199
|
+
#
|
|
200
|
+
# The verbose multi-line disclosure block was removed (it cluttered logs + the
|
|
201
|
+
# run experience, and its "off by default" copy no longer matches the on-by-
|
|
202
|
+
# default-for-individuals model). Disclosure now lives in two non-noisy places,
|
|
203
|
+
# so on-by-default collection is still NEVER covert:
|
|
204
|
+
# 1. a single line on the one-time welcome screen (cmd_welcome_terminal), shown
|
|
205
|
+
# only when diagnostics are actually on, and
|
|
206
|
+
# 2. docs/PRIVACY.md (canonical), plus `loki telemetry` status/off.
|
|
207
|
+
# Kept as a callable no-op so existing callers (run.sh) do not break, and still
|
|
208
|
+
# records the DISCLOSURE_SHOWN sentinel for back-compat with any reader of it.
|
|
163
209
|
loki_show_disclosure_once() {
|
|
164
210
|
local config="${HOME}/.loki/config"
|
|
165
|
-
|
|
166
|
-
# Already shown? Never re-show.
|
|
167
211
|
if [ -f "$config" ] && grep -q "^DISCLOSURE_SHOWN=true" "$config" 2>/dev/null; then
|
|
168
212
|
return 0
|
|
169
213
|
fi
|
|
170
|
-
|
|
171
|
-
# Disclosure copy (opt-in framing; no emojis, no em dashes).
|
|
172
|
-
{
|
|
173
|
-
echo ""
|
|
174
|
-
echo "Loki Mode anonymous diagnostics are OFF by default. Nothing is collected"
|
|
175
|
-
echo "or sent unless you opt in, so a default install sends us no telemetry"
|
|
176
|
-
echo "or diagnostics. Air-gapped and enterprise deployments are safe out of"
|
|
177
|
-
echo "the box."
|
|
178
|
-
echo "If you opt in, we send anonymous diagnostics only (os, arch, version,"
|
|
179
|
-
echo "error type, sanitized stack signatures). Never your code, prompts, paths,"
|
|
180
|
-
echo "keys, or repo names."
|
|
181
|
-
echo "See docs/PRIVACY.md. Opt in anytime with: loki telemetry on"
|
|
182
|
-
echo ""
|
|
183
|
-
} >&2
|
|
184
|
-
|
|
185
|
-
# Persist the sentinel (best-effort; never fail the caller).
|
|
186
214
|
mkdir -p "${HOME}/.loki" 2>/dev/null || return 0
|
|
187
215
|
echo "DISCLOSURE_SHOWN=true" >> "$config" 2>/dev/null || true
|
|
188
|
-
|
|
189
216
|
return 0
|
|
190
217
|
}
|
|
@@ -109,6 +109,27 @@ def _usage_section(usage_text, header):
|
|
|
109
109
|
return "\n".join(out)
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def _strip_md_fences(text):
|
|
113
|
+
"""Remove markdown code-fence lines (``` or ```lang) from a USAGE.md section
|
|
114
|
+
body. USAGE.md already wraps commands in fenced blocks; own re-wraps each
|
|
115
|
+
section in ONE fence, so leaving the inner fences in produces nested,
|
|
116
|
+
broken-rendering ``` inside ``` for the non-technical reader. We drop the
|
|
117
|
+
fence delimiter lines but keep the content + prose between them verbatim."""
|
|
118
|
+
if not text:
|
|
119
|
+
return ""
|
|
120
|
+
kept = []
|
|
121
|
+
for line in text.splitlines():
|
|
122
|
+
if line.lstrip().startswith("```"):
|
|
123
|
+
continue # drop the fence delimiter line itself
|
|
124
|
+
kept.append(line)
|
|
125
|
+
# Re-trim blank edges left behind after removing fences.
|
|
126
|
+
while kept and not kept[0].strip():
|
|
127
|
+
kept.pop(0)
|
|
128
|
+
while kept and not kept[-1].strip():
|
|
129
|
+
kept.pop()
|
|
130
|
+
return "\n".join(kept)
|
|
131
|
+
|
|
132
|
+
|
|
112
133
|
# ---------------------------------------------------------------------------
|
|
113
134
|
# honesty gate (the core)
|
|
114
135
|
# ---------------------------------------------------------------------------
|
|
@@ -209,6 +230,36 @@ def _run_id_when(run_id):
|
|
|
209
230
|
return ""
|
|
210
231
|
|
|
211
232
|
|
|
233
|
+
def _verdict_translation(proof):
|
|
234
|
+
"""A one-sentence plain-language translation of the honesty verdict for a
|
|
235
|
+
non-technical owner. Honesty gate is sacred here:
|
|
236
|
+
|
|
237
|
+
- ready (_is_ready True) -> a true, non-overclaiming positive line.
|
|
238
|
+
- partial ("VERIFIED ..." headline that is NOT a clean pass, e.g.
|
|
239
|
+
"VERIFIED WITH GAPS") -> "the code is there and it builds, but Loki
|
|
240
|
+
could not fully prove it works - see what is unverified below."
|
|
241
|
+
- everything else (NOT VERIFIED, missing/unknown headline) -> a plain,
|
|
242
|
+
NON-reassuring statement. NEVER softened into something comforting.
|
|
243
|
+
|
|
244
|
+
Returns '' when there is nothing safe to add (we never invent reassurance).
|
|
245
|
+
"""
|
|
246
|
+
headline = str(((proof or {}).get("honesty") or {}).get("headline")
|
|
247
|
+
or "").strip().upper()
|
|
248
|
+
if _is_ready(proof):
|
|
249
|
+
return ("In plain terms: Loki built what you asked for and checked that "
|
|
250
|
+
"it works.")
|
|
251
|
+
# Partial verification: the build produced something Loki could confirm
|
|
252
|
+
# partially, but not fully. Gated on the headline AFFIRMING verification
|
|
253
|
+
# (starts with VERIFIED) while NOT being a clean ready pass. "NOT VERIFIED"
|
|
254
|
+
# starts with "NOT" so it can never reach this branch.
|
|
255
|
+
if headline.startswith("VERIFIED"):
|
|
256
|
+
return ("In plain terms: the code is there and the build ran, but Loki "
|
|
257
|
+
"could not fully prove it works - see what is unverified below.")
|
|
258
|
+
# No affirmative verification at all. State it plainly, do not reassure.
|
|
259
|
+
return ("In plain terms: Loki could not confirm this build works. Treat it "
|
|
260
|
+
"as unfinished until the gaps below are resolved.")
|
|
261
|
+
|
|
262
|
+
|
|
212
263
|
def _section_is_it_working(proof, run_id=None):
|
|
213
264
|
"""2. 'Is it working?' - VERBATIM honesty.headline gating.
|
|
214
265
|
|
|
@@ -226,6 +277,8 @@ def _section_is_it_working(proof, run_id=None):
|
|
|
226
277
|
lines.append("Yes. Loki verified this build: the tests passed and the "
|
|
227
278
|
"build ran cleanly.")
|
|
228
279
|
lines.append("")
|
|
280
|
+
lines.append(_verdict_translation(proof))
|
|
281
|
+
lines.append("")
|
|
229
282
|
lines.append("Verdict (Loki's honest receipt): %s" % (headline or "VERIFIED"))
|
|
230
283
|
lines.append("")
|
|
231
284
|
lines.extend(_build_age_note(run_id))
|
|
@@ -238,6 +291,8 @@ def _section_is_it_working(proof, run_id=None):
|
|
|
238
291
|
else:
|
|
239
292
|
lines.append("Not verified. Loki did not record a verdict for this run.")
|
|
240
293
|
lines.append("")
|
|
294
|
+
lines.append(_verdict_translation(proof))
|
|
295
|
+
lines.append("")
|
|
241
296
|
lines.append("This means Loki is NOT telling you it is ready to ship. Here is "
|
|
242
297
|
"what was not verified:")
|
|
243
298
|
lines.append("")
|
|
@@ -266,9 +321,11 @@ def _section_run_on_computer(proof, usage_text, live_url):
|
|
|
266
321
|
if live_url:
|
|
267
322
|
lines.append("It is running right now on this machine at: %s" % live_url)
|
|
268
323
|
lines.append("")
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
324
|
+
# Strip USAGE.md's own ``` fences so each section is wrapped exactly once
|
|
325
|
+
# below (otherwise nested fences render broken for the non-dev reader).
|
|
326
|
+
start = _strip_md_fences(_usage_section(usage_text, "Start"))
|
|
327
|
+
verify = _strip_md_fences(_usage_section(usage_text, "Verify"))
|
|
328
|
+
install = _strip_md_fences(_usage_section(usage_text, "Install"))
|
|
272
329
|
if install:
|
|
273
330
|
lines.append("First, install it:")
|
|
274
331
|
lines.append("")
|
|
@@ -365,8 +422,16 @@ def _section_verified(proof, run_id):
|
|
|
365
422
|
|
|
366
423
|
|
|
367
424
|
def _section_still_to_do(proof, completion):
|
|
368
|
-
"""7. 'What you still need to do or decide'.
|
|
425
|
+
"""7. 'What you still need to do or decide'.
|
|
426
|
+
|
|
427
|
+
Rendered as a numbered, ordered checklist so a non-technical owner knows
|
|
428
|
+
exactly what to handle and roughly in what order: run/try it, resolve
|
|
429
|
+
anything Loki could not verify, then open a PR (merge) and deploy. Every
|
|
430
|
+
item still maps to a real artifact value -- nothing is invented.
|
|
431
|
+
"""
|
|
369
432
|
lines = ["## What you still need to do or decide", ""]
|
|
433
|
+
# Action items, kept in a sensible do-this-first order:
|
|
434
|
+
# 1) review assumptions, 2) resolve unverified gaps, 3) PR/merge, 4) deploy.
|
|
370
435
|
items = []
|
|
371
436
|
|
|
372
437
|
# Assumptions Loki had to make where the spec was ambiguous.
|
|
@@ -416,9 +481,12 @@ def _section_still_to_do(proof, completion):
|
|
|
416
481
|
if not str(deployment.get("deployed_url") or "").strip():
|
|
417
482
|
items.append("It is not deployed yet. Use `loki deploy` when you are ready.")
|
|
418
483
|
|
|
419
|
-
|
|
420
|
-
lines.append("
|
|
421
|
-
|
|
484
|
+
if items:
|
|
485
|
+
lines.append("Work through these in order:")
|
|
486
|
+
lines.append("")
|
|
487
|
+
for i, it in enumerate(items, start=1):
|
|
488
|
+
lines.append("%d. %s" % (i, it))
|
|
489
|
+
else:
|
|
422
490
|
lines.append("- Nothing outstanding was recorded. Read the sections above "
|
|
423
491
|
"to decide your next step.")
|
|
424
492
|
return lines
|
|
@@ -461,7 +529,9 @@ def render_markdown(loki_dir):
|
|
|
461
529
|
usage_text = _read_text(os.path.join(project_root, "USAGE.md"))
|
|
462
530
|
live_url = _live_url(loki_dir)
|
|
463
531
|
|
|
464
|
-
blocks = [["# What Loki built for you", ""
|
|
532
|
+
blocks = [["# What Loki built for you", "",
|
|
533
|
+
"Here is what Loki built and how to take it from here, in plain "
|
|
534
|
+
"language - no code reading required."]]
|
|
465
535
|
blocks.append(_section_what_you_have(proof))
|
|
466
536
|
blocks.append(_section_is_it_working(proof, run_id))
|
|
467
537
|
blocks.append(_section_run_on_computer(proof, usage_text, live_url))
|
|
@@ -550,6 +550,19 @@ def _collect_spec(loki_dir, target_dir):
|
|
|
550
550
|
return {"source": source, "brief": brief}
|
|
551
551
|
|
|
552
552
|
|
|
553
|
+
def _self_version():
|
|
554
|
+
"""Read the installed Loki version from the VERSION file shipped beside this
|
|
555
|
+
generator (package layout: <root>/VERSION and <root>/autonomy/lib/<this>).
|
|
556
|
+
|
|
557
|
+
This is the most robust source: proof-generator.py always ships two dirs
|
|
558
|
+
below VERSION in every distribution channel (npm, Docker, brew), so it is
|
|
559
|
+
correct regardless of the caller's cwd or the target app dir. Returns "" when
|
|
560
|
+
the file cannot be read (never raises)."""
|
|
561
|
+
return _read_text(
|
|
562
|
+
os.path.join(_HERE, "..", "..", "VERSION")
|
|
563
|
+
).strip()
|
|
564
|
+
|
|
565
|
+
|
|
553
566
|
def _collect_meta(loki_dir, repo_root):
|
|
554
567
|
orch = _read_json(
|
|
555
568
|
os.path.join(loki_dir, "state", "orchestrator.json"), default={}
|
|
@@ -560,6 +573,11 @@ def _collect_meta(loki_dir, repo_root):
|
|
|
560
573
|
version = str(orch.get("version") or "")
|
|
561
574
|
if not version and repo_root:
|
|
562
575
|
version = _read_text(os.path.join(repo_root, "VERSION")).strip()
|
|
576
|
+
# Final fallback: the VERSION shipped beside this generator. Robust even when
|
|
577
|
+
# repo_root resolution failed (e.g. the generator runs from outside its
|
|
578
|
+
# package tree against a user app dir that has no VERSION file).
|
|
579
|
+
if not version:
|
|
580
|
+
version = _self_version()
|
|
563
581
|
return started_at, version
|
|
564
582
|
|
|
565
583
|
|
|
@@ -610,7 +628,15 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
|
|
|
610
628
|
run_id = args.run_id or os.environ.get("LOKI_SESSION_ID") or _gen_run_id()
|
|
611
629
|
|
|
612
630
|
started_at, version_from_state = _collect_meta(loki_dir, repo_root)
|
|
613
|
-
|
|
631
|
+
# Treat a literal "unknown" arg as absent: the bash runtime wrapper passes
|
|
632
|
+
# --loki-version "$(get_version ... || echo unknown)", and get_version is not
|
|
633
|
+
# defined in run.sh's process, so the wrapper sends the sentinel "unknown".
|
|
634
|
+
# Letting that win would mask the version that _collect_meta resolves from
|
|
635
|
+
# orchestrator.json / repo VERSION / the VERSION shipped beside this file.
|
|
636
|
+
arg_version = (args.loki_version or "").strip()
|
|
637
|
+
if arg_version.lower() == "unknown":
|
|
638
|
+
arg_version = ""
|
|
639
|
+
loki_version = arg_version or version_from_state or "unknown"
|
|
614
640
|
|
|
615
641
|
cost, model_from_eff = _collect_efficiency(loki_dir)
|
|
616
642
|
provider_name = args.provider or os.environ.get("PROVIDER_NAME") or "claude"
|
|
@@ -86,6 +86,180 @@ def _entry_points(root, files):
|
|
|
86
86
|
return [c for c in candidates if c in fileset]
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def _mermaid_label(text):
|
|
90
|
+
"""Return a Mermaid-safe node label string (no injection, no parse breaks).
|
|
91
|
+
|
|
92
|
+
Mermaid breaks on quotes, brackets, and a handful of metacharacters, and a
|
|
93
|
+
crafted label could otherwise smuggle node/edge syntax. We keep only a
|
|
94
|
+
conservative character set (alphanumerics, space, and a few path-safe
|
|
95
|
+
punctuation marks) and collapse everything else to a space. The result is
|
|
96
|
+
always wrapped by the caller in double quotes inside ["..."], so the empty
|
|
97
|
+
string degrades to an empty-but-valid label rather than a syntax error.
|
|
98
|
+
"""
|
|
99
|
+
safe = []
|
|
100
|
+
for ch in str(text or ""):
|
|
101
|
+
if ch.isalnum() or ch in " ._/-":
|
|
102
|
+
safe.append(ch)
|
|
103
|
+
else:
|
|
104
|
+
safe.append(" ")
|
|
105
|
+
out = "".join(safe).strip()
|
|
106
|
+
# Collapse runs of whitespace so labels stay compact + deterministic.
|
|
107
|
+
out = " ".join(out.split())
|
|
108
|
+
return out or "node"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _classify_data_store(rel):
|
|
112
|
+
"""Return a data-store label for a file that looks like a store, else None.
|
|
113
|
+
|
|
114
|
+
Heuristic + deterministic: matches well-known persistence/config surfaces
|
|
115
|
+
by path substring. Only real indexed files reach here, so any node emitted
|
|
116
|
+
is cited. Returns None when the file is not a recognizable data store.
|
|
117
|
+
"""
|
|
118
|
+
low = rel.lower()
|
|
119
|
+
checks = [
|
|
120
|
+
("schema", "Schema"),
|
|
121
|
+
("migration", "Migrations"),
|
|
122
|
+
("models", "Data Models"),
|
|
123
|
+
("model.", "Data Models"),
|
|
124
|
+
("storage", "Storage"),
|
|
125
|
+
("database", "Database"),
|
|
126
|
+
("/db/", "Database"),
|
|
127
|
+
("db.", "Database"),
|
|
128
|
+
("repository", "Repository"),
|
|
129
|
+
("repositories", "Repository"),
|
|
130
|
+
("dao", "Data Access"),
|
|
131
|
+
("store.", "Store"),
|
|
132
|
+
(".sql", "SQL"),
|
|
133
|
+
]
|
|
134
|
+
for needle, label in checks:
|
|
135
|
+
if needle in low:
|
|
136
|
+
return label
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _data_stores(files, limit=4):
|
|
141
|
+
"""Pick recognizable data-store files from the indexed set (real files).
|
|
142
|
+
|
|
143
|
+
Deterministic: scans files in sorted order (build_index returns them
|
|
144
|
+
sorted) and returns the first `limit` matches as {file, label}.
|
|
145
|
+
"""
|
|
146
|
+
stores = []
|
|
147
|
+
for rel in files:
|
|
148
|
+
label = _classify_data_store(rel)
|
|
149
|
+
if label:
|
|
150
|
+
stores.append({"file": rel, "label": label})
|
|
151
|
+
if len(stores) >= limit:
|
|
152
|
+
break
|
|
153
|
+
return stores
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _architecture_diagram(index, modules, entries):
|
|
157
|
+
"""Deterministic Mermaid flowchart: entry points -> modules -> data stores.
|
|
158
|
+
|
|
159
|
+
Every node is derived from the real codebase index (entry points, top
|
|
160
|
+
modules, data-store files) -- nothing is fabricated. Given the same index
|
|
161
|
+
the output is byte-identical (no Date, no random, fixed iteration order).
|
|
162
|
+
If the index is too sparse to draw a real graph, a minimal honest
|
|
163
|
+
single-node flowchart is returned instead of a fake one.
|
|
164
|
+
"""
|
|
165
|
+
entry_nodes = list(entries[:4])
|
|
166
|
+
entry_set = set(entry_nodes)
|
|
167
|
+
# A file that is both an entry point and a top module is drawn once, as an
|
|
168
|
+
# entry point, so a node is never declared twice and no self-edge appears.
|
|
169
|
+
mod_nodes = [m["file"] for m in modules[:6] if m["file"] not in entry_set]
|
|
170
|
+
stores = _data_stores(index["files"])
|
|
171
|
+
|
|
172
|
+
# Sparse-index guard: with no entry points and no modules there is nothing
|
|
173
|
+
# real to draw. Emit a minimal honest diagram rather than inventing nodes.
|
|
174
|
+
if not entry_nodes and not mod_nodes:
|
|
175
|
+
return "flowchart TD\n src[\"Source files\"]"
|
|
176
|
+
|
|
177
|
+
lines = ["flowchart TD"]
|
|
178
|
+
ids = {}
|
|
179
|
+
counter = 0
|
|
180
|
+
|
|
181
|
+
def node_id(key):
|
|
182
|
+
nonlocal counter
|
|
183
|
+
if key not in ids:
|
|
184
|
+
ids[key] = "n%d" % counter
|
|
185
|
+
counter += 1
|
|
186
|
+
return ids[key]
|
|
187
|
+
|
|
188
|
+
# Declare nodes in a fixed order (entries, modules, stores) so the diagram
|
|
189
|
+
# is deterministic for a given index.
|
|
190
|
+
for e in entry_nodes:
|
|
191
|
+
lines.append(" %s[\"%s\"]" % (node_id(e), _mermaid_label(e)))
|
|
192
|
+
for m in mod_nodes:
|
|
193
|
+
lines.append(" %s[\"%s\"]" % (node_id(m), _mermaid_label(m)))
|
|
194
|
+
for s in stores:
|
|
195
|
+
lines.append(
|
|
196
|
+
" %s[(\"%s\")]" % (node_id("store:" + s["file"]), _mermaid_label(s["label"]))
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Edges: every entry point feeds every top module (a coarse but honest
|
|
200
|
+
# "entry -> module" relation), and modules feed the data stores. When there
|
|
201
|
+
# are no entry points, modules stand alone at the top.
|
|
202
|
+
sources = entry_nodes if entry_nodes else mod_nodes
|
|
203
|
+
targets = mod_nodes if entry_nodes else []
|
|
204
|
+
for src in sources:
|
|
205
|
+
for tgt in targets:
|
|
206
|
+
lines.append(" %s --> %s" % (node_id(src), node_id(tgt)))
|
|
207
|
+
if stores:
|
|
208
|
+
store_sources = mod_nodes if mod_nodes else entry_nodes
|
|
209
|
+
for src in store_sources[:3]:
|
|
210
|
+
for s in stores:
|
|
211
|
+
lines.append(
|
|
212
|
+
" %s --> %s" % (node_id(src), node_id("store:" + s["file"]))
|
|
213
|
+
)
|
|
214
|
+
return "\n".join(lines)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _data_flow_diagram(index, modules, entries):
|
|
218
|
+
"""Deterministic Mermaid flowchart for the request/data path.
|
|
219
|
+
|
|
220
|
+
Models the path as: entry point -> the top modules in rank order -> data
|
|
221
|
+
store, using only real indexed files. Same index -> same diagram. Falls
|
|
222
|
+
back to a minimal honest diagram when the index is too sparse.
|
|
223
|
+
"""
|
|
224
|
+
entry = entries[0] if entries else None
|
|
225
|
+
# Drop the entry file from the module chain so it is not visited twice
|
|
226
|
+
# (which would create a self-edge); the chain stays a simple acyclic path.
|
|
227
|
+
mod_chain = [m["file"] for m in modules[:4] if m["file"] != entry]
|
|
228
|
+
stores = _data_stores(index["files"], limit=1)
|
|
229
|
+
|
|
230
|
+
if not entry and not mod_chain:
|
|
231
|
+
return "flowchart LR\n src[\"Source files\"]"
|
|
232
|
+
|
|
233
|
+
lines = ["flowchart LR"]
|
|
234
|
+
ids = {}
|
|
235
|
+
counter = 0
|
|
236
|
+
|
|
237
|
+
def node_id(key):
|
|
238
|
+
nonlocal counter
|
|
239
|
+
if key not in ids:
|
|
240
|
+
ids[key] = "f%d" % counter
|
|
241
|
+
counter += 1
|
|
242
|
+
return ids[key]
|
|
243
|
+
|
|
244
|
+
# Build an ordered chain of real nodes: entry -> modules -> store.
|
|
245
|
+
chain = []
|
|
246
|
+
if entry:
|
|
247
|
+
chain.append(("entry", entry, _mermaid_label(entry)))
|
|
248
|
+
for m in mod_chain:
|
|
249
|
+
chain.append(("mod", m, _mermaid_label(m)))
|
|
250
|
+
if stores:
|
|
251
|
+
chain.append(("store", "store:" + stores[0]["file"], _mermaid_label(stores[0]["label"])))
|
|
252
|
+
|
|
253
|
+
for kind, key, label in chain:
|
|
254
|
+
if kind == "store":
|
|
255
|
+
lines.append(" %s[(\"%s\")]" % (node_id(key), label))
|
|
256
|
+
else:
|
|
257
|
+
lines.append(" %s[\"%s\"]" % (node_id(key), label))
|
|
258
|
+
for i in range(len(chain) - 1):
|
|
259
|
+
lines.append(" %s --> %s" % (node_id(chain[i][1]), node_id(chain[i + 1][1])))
|
|
260
|
+
return "\n".join(lines)
|
|
261
|
+
|
|
262
|
+
|
|
89
263
|
def _llm_prose(section, context, fallback):
|
|
90
264
|
"""Get prose for a section from the LLM, or use the deterministic fallback."""
|
|
91
265
|
prompt = (
|
|
@@ -137,8 +311,11 @@ def _section_overview(root, index, modules, entries, context):
|
|
|
137
311
|
for m in modules[:4]:
|
|
138
312
|
line = m["defs"][0]["line"] if m["defs"] else 1
|
|
139
313
|
citations.append({"file": m["file"], "line": line})
|
|
314
|
+
# Mermaid flowchart derived from the real index (entry -> modules -> stores).
|
|
315
|
+
# Raw mermaid source (no ``` wrapper); the UI wraps/renders it.
|
|
316
|
+
diagram = _architecture_diagram(index, modules, entries)
|
|
140
317
|
return {"id": "architecture", "title": "Architecture Overview",
|
|
141
|
-
"body": prose, "citations": citations}
|
|
318
|
+
"body": prose, "citations": citations, "diagram": diagram}
|
|
142
319
|
|
|
143
320
|
|
|
144
321
|
def _section_modules(root, modules, context):
|
|
@@ -165,7 +342,7 @@ def _section_modules(root, modules, context):
|
|
|
165
342
|
"body": "\n".join(body_parts), "citations": citations}
|
|
166
343
|
|
|
167
344
|
|
|
168
|
-
def _section_data_flow(root, index, entries, context):
|
|
345
|
+
def _section_data_flow(root, index, modules, entries, context):
|
|
169
346
|
fallback = (
|
|
170
347
|
"Execution begins at the entry point(s) (%s) and flows through the "
|
|
171
348
|
"key modules. Trace a request from the entry file into the modules it "
|
|
@@ -175,8 +352,11 @@ def _section_data_flow(root, index, entries, context):
|
|
|
175
352
|
citations = [{"file": e, "line": 1} for e in entries[:4]]
|
|
176
353
|
if not citations and index["files"]:
|
|
177
354
|
citations = [{"file": index["files"][0], "line": 1}]
|
|
355
|
+
# Mermaid data-flow chain derived from the real index (entry -> modules ->
|
|
356
|
+
# store). Raw mermaid source (no ``` wrapper); the UI wraps/renders it.
|
|
357
|
+
diagram = _data_flow_diagram(index, modules, entries)
|
|
178
358
|
return {"id": "data-flow", "title": "Data Flow",
|
|
179
|
-
"body": prose, "citations": citations}
|
|
359
|
+
"body": prose, "citations": citations, "diagram": diagram}
|
|
180
360
|
|
|
181
361
|
|
|
182
362
|
def _validate_citations(root, citations):
|
|
@@ -203,6 +383,14 @@ def _validate_citations(root, citations):
|
|
|
203
383
|
|
|
204
384
|
def _render_md(section):
|
|
205
385
|
lines = ["## %s" % section["title"], "", section["body"], ""]
|
|
386
|
+
# Render the Mermaid diagram (when present) as a fenced mermaid block so
|
|
387
|
+
# the markdown view shows the same visual the dashboard renders.
|
|
388
|
+
diagram = section.get("diagram")
|
|
389
|
+
if diagram:
|
|
390
|
+
lines.append("```mermaid")
|
|
391
|
+
lines.append(diagram)
|
|
392
|
+
lines.append("```")
|
|
393
|
+
lines.append("")
|
|
206
394
|
if section["citations"]:
|
|
207
395
|
lines.append("**Sources:**")
|
|
208
396
|
for c in section["citations"]:
|
|
@@ -259,7 +447,7 @@ def main(argv=None):
|
|
|
259
447
|
sections = [
|
|
260
448
|
_section_overview(root, index, modules, entries, context),
|
|
261
449
|
_section_modules(root, modules, context),
|
|
262
|
-
_section_data_flow(root, index, entries, context),
|
|
450
|
+
_section_data_flow(root, index, modules, entries, context),
|
|
263
451
|
]
|
|
264
452
|
|
|
265
453
|
# Enforce the grounding contract: validate every citation against disk.
|