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 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.88.0
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.88.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.89.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.88.0
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
- # 1. Any opt-out flag present -> OFF (hard kill, always wins)
44
- # 2. Else any opt-in flag present -> ON
45
- # 3. Else (default) -> OFF (no egress, no local capture)
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. Opt-in required to enable ---
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. Default: OFF ---
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: print the first-run disclosure exactly once.
160
- # Shown regardless of enabled/disabled state, before any egress. Uses the
161
- # DISCLOSURE_SHOWN sentinel in ~/.loki/config (do not invent a new file).
162
- # Safe if HOME is unwritable (best-effort).
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
- start = _usage_section(usage_text, "Start")
270
- verify = _usage_section(usage_text, "Verify")
271
- install = _usage_section(usage_text, "Install")
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
- for it in items:
420
- lines.append("- %s" % it)
421
- if not items:
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
- loki_version = args.loki_version or version_from_state or "unknown"
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.