loki-mode 7.87.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.
@@ -820,15 +820,37 @@ print("%d %d" % (total, high))
820
820
  # resolution (sets confirmed=true). This is deliberately the same teeth the
821
821
  # LOKI_ASSUMPTIONS_REQUIRE_CONFIRM=1 path applies to ALL entries, scoped here to
822
822
  # just contradictions in default autonomous mode. See the design note below.
823
+ #
824
+ # F50 (zero-config brief mode): a one-line-brief run (LOKI_TTFV=brief) is the
825
+ # zero-config first-pass path. There is no human in the loop and the audience
826
+ # is non-technical: a permanently-blocking contradiction makes a brief run grind
827
+ # to max-iterations and NEVER cleanly complete -- a broken first-run UX for the
828
+ # exact users the brief path exists to serve. Worse, a synthetic brief-PRD that
829
+ # Loki itself wrote is unlikely to carry a real human-authored contradiction;
830
+ # leaving the gate blocking yields no signal a non-technical user can act on.
831
+ # So in brief mode contradictions are RESOLVED-WITH-DEFAULT, not blocked: Loki
832
+ # acknowledges the entry, records resolved_with_default=true plus an honest
833
+ # resolution note, and the gate is allowed to clear. This is NOT hiding the gap:
834
+ # spec_ledger_counts still counts the entry, build_completion_summary still folds
835
+ # it into the proof-of-done, ledger.md still lists it, and `loki own` still tells
836
+ # the user to review the assumptions Loki had to make. PRD mode (a real
837
+ # human-authored spec, where a human CAN resolve a contradiction) keeps the
838
+ # original BLOCKING behavior unchanged. brief mode is detected from LOKI_TTFV=brief
839
+ # (set by cmd_start) and is overridable by the explicit param for tests/callers.
840
+ # Usage: spec_ledger_acknowledge_all [brief_mode] # brief_mode: "brief" to resolve contradictions
823
841
  # ---------------------------------------------------------------------------
824
842
  spec_ledger_acknowledge_all() {
825
843
  [ "${LOKI_ASSUMPTIONS_REQUIRE_CONFIRM:-0}" = "1" ] && return 0
826
- local dir
844
+ local dir brief_mode
845
+ # F50: brief/zero-config mode resolves contradictions with a default rather
846
+ # than blocking forever. Explicit param wins; otherwise read LOKI_TTFV.
847
+ brief_mode="${1:-${LOKI_TTFV:-}}"
827
848
  dir="$(_spec_ledger_dir)"
828
849
  [ -d "$dir" ] || return 0
829
- _SL_DIR="$dir" python3 -c '
850
+ _SL_DIR="$dir" _SL_BRIEF="$brief_mode" python3 -c '
830
851
  import glob, json, os, tempfile
831
852
  d = os.environ["_SL_DIR"]
853
+ brief = os.environ.get("_SL_BRIEF", "") == "brief"
832
854
  for p in glob.glob(os.path.join(d, "a-*.json")):
833
855
  try:
834
856
  with open(p) as f:
@@ -837,10 +859,22 @@ for p in glob.glob(os.path.join(d, "a-*.json")):
837
859
  continue
838
860
  if r.get("acknowledged"):
839
861
  continue
840
- # P2-4: a contradiction cannot be assumed away, so it is never auto-acked.
841
- if r.get("class") == "contradictory":
862
+ is_contradiction = r.get("class") == "contradictory"
863
+ # P2-4: a contradiction cannot be assumed away, so it is never auto-acked
864
+ # in default / PRD mode (a human can resolve it there).
865
+ if is_contradiction and not brief:
842
866
  continue
843
867
  r["acknowledged"] = True
868
+ # F50: brief-mode contradictions are resolved-with-default, marked honestly
869
+ # so the receipt / ledger / `loki own` can show "Loki chose a default here".
870
+ if is_contradiction and brief:
871
+ r["resolved_with_default"] = True
872
+ if not r.get("resolution_note"):
873
+ r["resolution_note"] = (
874
+ "Resolved with a reasonable default in zero-config brief mode "
875
+ "(no human in the loop). Review and refine via a full PRD run "
876
+ "(loki start ./prd.md) if this default is wrong."
877
+ )
844
878
  fd, tmp = tempfile.mkstemp(dir=os.path.dirname(p), suffix=".tmp")
845
879
  try:
846
880
  with os.fdopen(fd, "w", encoding="utf-8") as f:
@@ -907,7 +941,17 @@ else:
907
941
  lines.append("Total assumptions: %d (%d high-severity)" % (len(entries), high))
908
942
  lines.append("")
909
943
  for e in entries:
910
- state = "confirmed" if e.get("confirmed") else ("acknowledged" if e.get("acknowledged") else "OPEN")
944
+ # F50: surface a resolved-with-default state distinctly so a brief-mode
945
+ # contradiction reads honestly ("Loki chose a default") rather than as a
946
+ # plain ack or a silent pass.
947
+ if e.get("confirmed"):
948
+ state = "confirmed"
949
+ elif e.get("resolved_with_default"):
950
+ state = "resolved-with-default"
951
+ elif e.get("acknowledged"):
952
+ state = "acknowledged"
953
+ else:
954
+ state = "OPEN"
911
955
  lines.append("## %s [%s / %s / %s]" % (e.get("id",""), e.get("severity",""), e.get("class",""), state))
912
956
  lines.append("")
913
957
  lines.append("- Gap: %s" % e.get("gap",""))
@@ -915,6 +959,8 @@ else:
915
959
  lines.append("- Why: %s" % e.get("why",""))
916
960
  lines.append("- Affects: %s" % e.get("affects",""))
917
961
  lines.append("- Source: %s" % e.get("source",""))
962
+ if e.get("resolution_note"):
963
+ lines.append("- Resolution: %s" % e.get("resolution_note"))
918
964
  lines.append("")
919
965
  out = os.path.join(d, "ledger.md")
920
966
  fd, tmp = tempfile.mkstemp(dir=d, suffix=".tmp")
@@ -1,24 +1,97 @@
1
1
  #!/usr/bin/env bash
2
2
  # Anonymous usage telemetry for Loki Mode
3
- # Collection is OPT-IN and OFF by default. Nothing is sent unless the user opts
4
- # in, so a default install never phones home (air-gapped / GDPR / FedRAMP safe).
5
- # Opt-in: LOKI_TELEMETRY=on OR ~/.loki/config: TELEMETRY_ENABLED=true
3
+ # Collection is ON BY DEFAULT for individual interactive installs, but AUTO-OFF
4
+ # in enterprise / CI / air-gapped contexts (see _loki_telemetry_auto_off), so
5
+ # enterprise + air-gapped deployments stay silent out of the box (GDPR / FedRAMP
6
+ # safe). Only anonymous diagnostics are ever sent (os, arch, version, error type,
7
+ # sanitized stack signatures); never code, prompts, paths, keys, or repo names.
6
8
  # Opt-out (always wins): LOKI_TELEMETRY=off / LOKI_TELEMETRY_DISABLED=true /
7
9
  # DO_NOT_TRACK=1 / ~/.loki/config: TELEMETRY_DISABLED=true
8
- # All calls are fire-and-forget, silent on failure, non-blocking
10
+ # Force-on: LOKI_TELEMETRY=on OR ~/.loki/config: TELEMETRY_ENABLED=true
11
+ # All calls are fire-and-forget, silent on failure, non-blocking.
12
+ # Disclosed in docs/PRIVACY.md + a one-time disclosure on first use (never covert).
13
+
14
+ # _loki_telemetry_auto_off: returns 0 (true, auto-disable) when the environment is
15
+ # enterprise / CI / non-interactive / air-gapped, where on-by-default would be
16
+ # inappropriate. Keeps "enterprise + air-gapped safe out of the box" literally
17
+ # true while still defaulting ON for ordinary individual users. Shared by every
18
+ # gate (crash.sh, dashboard/telemetry.py mirror this list).
19
+ # - CI/automation: CI / GITHUB_ACTIONS / GITLAB_CI / BUILDKITE / JENKINS_URL /
20
+ # TEAMCITY_VERSION / CONTINUOUS_INTEGRATION
21
+ # - Enterprise opt-out: LOKI_ENTERPRISE=true / LOKI_AIRGAP=true
22
+ # - Non-interactive: no TTY on stdout AND stdin (scripts/pipes/detached)
23
+ _loki_telemetry_auto_off() {
24
+ [ "${CI:-}" = "true" ] && return 0
25
+ [ -n "${GITHUB_ACTIONS:-}" ] && return 0
26
+ [ -n "${GITLAB_CI:-}" ] && return 0
27
+ [ -n "${BUILDKITE:-}" ] && return 0
28
+ [ -n "${JENKINS_URL:-}" ] && return 0
29
+ [ -n "${TEAMCITY_VERSION:-}" ] && return 0
30
+ [ "${CONTINUOUS_INTEGRATION:-}" = "true" ] && return 0
31
+ [ "${LOKI_ENTERPRISE:-}" = "true" ] && return 0
32
+ [ "${LOKI_AIRGAP:-}" = "true" ] && return 0
33
+ # Non-interactive detection (council cH_r1 AC2). Interactivity is resolved
34
+ # EXACTLY ONCE at the real entry point (bin/loki shim top / autonomy/loki
35
+ # main), while the user's TTY is present, and exported as LOKI_TTY_INTERACTIVE.
36
+ # We must trust that explicit signal here instead of a fresh `-t` probe,
37
+ # because this gate runs inside FD-detached subshells (the bin/loki gate-check
38
+ # and the backgrounded emit) where a `-t` probe always sees non-TTY and would
39
+ # wrongly auto-off a real interactive user.
40
+ # Precedence:
41
+ # - LOKI_TTY_INTERACTIVE=1 -> interactive (do NOT auto-off here)
42
+ # - LOKI_TTY_INTERACTIVE=0/other set -> non-interactive (auto-off)
43
+ # - UNSET (gate ran without passing an entry point, e.g. an isolated unit
44
+ # test) -> fall back to the live `-t` probe so isolated gate tests behave.
45
+ if [ -n "${LOKI_TTY_INTERACTIVE:-}" ]; then
46
+ [ "${LOKI_TTY_INTERACTIVE}" = "1" ] && return 1
47
+ return 0
48
+ fi
49
+ if [ ! -t 1 ] && [ ! -t 0 ]; then
50
+ return 0
51
+ fi
52
+ return 1
53
+ }
9
54
 
10
55
  LOKI_POSTHOG_HOST="${LOKI_TELEMETRY_ENDPOINT:-https://us.i.posthog.com}"
11
56
  LOKI_POSTHOG_KEY="phc_ya0vGBru41AJWtGNfZZ8H9W4yjoZy4KON0nnayS7s87"
12
57
 
58
+ # _loki_disclose_telemetry_once: one-time, route-shared disclosure (council
59
+ # cH_r1 AC4). Prints a single anonymous-diagnostics disclosure to the user's
60
+ # REAL stderr the first time collection is ACTUALLY enabled at egress time, then
61
+ # records its OWN marker so it never repeats. This is the SINGLE shared impl used
62
+ # by BOTH the Bun route (bin/loki, which sources this file at top level just for
63
+ # this helper) and the bash route (autonomy/loki main, before its foreground
64
+ # cli_command egress) so no first command is ever covert and the copy never
65
+ # drifts between routes. Keyed on ~/.loki/.telemetry-disclosed, NOT the
66
+ # .loki-first-run sentinel: a first run in CI/auto-off sets .loki-first-run while
67
+ # suppressing disclosure, which must NOT permanently silence the disclosure on a
68
+ # later interactive (enabled) run (the sentinel edge, AC5). Guarded so a single
69
+ # definition wins if both this file and another loader define it.
70
+ if ! declare -f _loki_disclose_telemetry_once >/dev/null 2>&1; then
71
+ _loki_disclose_telemetry_once() {
72
+ local _marker="${HOME}/.loki/.telemetry-disclosed"
73
+ [ -f "$_marker" ] 2>/dev/null && return 0
74
+ printf '%s\n' \
75
+ "Loki Mode sends anonymous diagnostics (os, arch, version, error type only --" \
76
+ "never your code, prompts, paths, or keys) to help fix bugs. Off in enterprise/" \
77
+ "CI/air-gapped setups. Turn it off anytime: loki telemetry off (docs/PRIVACY.md)" >&2
78
+ mkdir -p "${HOME}/.loki" 2>/dev/null || return 0
79
+ : > "$_marker" 2>/dev/null || true
80
+ return 0
81
+ }
82
+ fi
83
+
13
84
  _loki_telemetry_enabled() {
14
- # Unified OPT-IN gate. Returns 0 (enabled) ONLY when the user opted in AND
15
- # did not also opt out. Opt-out always wins; default is OFF. This precedence
85
+ # Unified gate. Default ON for individual interactive installs; auto-OFF in
86
+ # enterprise/CI/air-gapped contexts; explicit opt-out always wins. Precedence
16
87
  # MUST mirror loki_collection_enabled in autonomy/crash.sh and _is_enabled in
17
88
  # dashboard/telemetry.py so one model gates BOTH usage telemetry and crash
18
89
  # reporting.
19
- # 1. Any opt-out flag present -> 1 (hard kill, always wins)
20
- # 2. Else any opt-in flag present -> 0
21
- # 3. Else (default) -> 1 (no egress)
90
+ # 1. Any opt-out flag present -> 1 (hard kill, always wins)
91
+ # 2. Else explicit opt-in present -> 0 (force-on, even in CI/enterprise)
92
+ # 3. Else enterprise/CI/air-gapped -> 1 (auto-off, safe out of the box)
93
+ # 4. Else (individual default) -> 0 (on, anonymous diagnostics)
94
+ # All enabled paths still require curl (no egress tool -> off).
22
95
  local _telem_lower
23
96
  _telem_lower="$(printf '%s' "${LOKI_TELEMETRY:-}" | tr '[:upper:]' '[:lower:]')"
24
97
 
@@ -30,7 +103,7 @@ _loki_telemetry_enabled() {
30
103
  return 1
31
104
  fi
32
105
 
33
- # --- 2. Opt-in required to enable ---
106
+ # --- 2. Explicit opt-in forces ON (overrides the enterprise/CI auto-off) ---
34
107
  if [ "$_telem_lower" = "on" ]; then
35
108
  command -v curl >/dev/null 2>&1 || return 1
36
109
  return 0
@@ -40,8 +113,12 @@ _loki_telemetry_enabled() {
40
113
  return 0
41
114
  fi
42
115
 
43
- # --- 3. Default: OFF ---
44
- return 1
116
+ # --- 3. Enterprise / CI / air-gapped: auto-off (safe out of the box) ---
117
+ _loki_telemetry_auto_off && return 1
118
+
119
+ # --- 4. Individual interactive default: ON (anonymous diagnostics) ---
120
+ command -v curl >/dev/null 2>&1 || return 1
121
+ return 0
45
122
  }
46
123
 
47
124
  _loki_telemetry_id() {
package/bin/loki CHANGED
@@ -32,6 +32,21 @@ SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
32
32
  REPO_ROOT=$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd)
33
33
  BASH_CLI="$REPO_ROOT/autonomy/loki"
34
34
 
35
+ # v7.89.0 telemetry TTY fix (council cH_r1): resolve interactivity EXACTLY ONCE
36
+ # here, while this shim still owns the user's real TTY, and export it as an
37
+ # explicit signal. The telemetry gates (telemetry.sh / crash.sh / dashboard
38
+ # telemetry.py) must NOT re-probe `-t` later: the gate is evaluated inside
39
+ # subshells that redirect FDs (the `) </dev/null >/dev/null 2>&1` gate-check
40
+ # subshells below), and the backgrounded emit subshell also detaches its FDs, so
41
+ # a fresh `-t` probe there always sees non-TTY and would auto-off a REAL
42
+ # interactive user (killing on-by-default + the disclosure). Set the var only
43
+ # when a terminal is actually present at this entry point; leave it UNSET
44
+ # otherwise so an isolated unit test that bypasses this entry point falls back to
45
+ # the gate's own `-t` probe (documented precedence in the *_auto_off helpers).
46
+ if [ -t 1 ] || [ -t 0 ]; then
47
+ export LOKI_TTY_INTERACTIVE=1
48
+ fi
49
+
35
50
  # Resolve which Bun entry to use:
36
51
  # 1. LOKI_TS_ENTRY=... -- explicit override (custom builds, tests)
37
52
  # 2. BUN_FROM_SOURCE=1 -- prefer src/cli.ts (used by bench --compare-dist
@@ -87,6 +102,42 @@ else
87
102
  exec "$BASH_CLI" "$@"
88
103
  fi
89
104
 
105
+ # v7.88.1 covert-path fix (council cF_r2 + cG_r2 sentinel edge): under on-by-default
106
+ # diagnostics, NO telemetry may egress before the user has seen a disclosure once.
107
+ # The cmd_start welcome only shows on `loki start`, so a first `loki version`/`doctor`
108
+ # would otherwise egress covertly. _loki_disclose_telemetry_once prints a one-time
109
+ # disclosure to the user's REAL stderr the first time collection is ACTUALLY enabled
110
+ # at egress time. It is keyed on its OWN marker (~/.loki/.telemetry-disclosed), NOT
111
+ # the .loki-first-run sentinel: the sentinel edge is that a first run in CI/auto-off
112
+ # sets .loki-first-run while suppressing disclosure, which must NOT permanently
113
+ # silence the disclosure on a later interactive (enabled) run. Every enabled egress
114
+ # path (installed + cli_command) calls this first, so whichever fires first discloses.
115
+ #
116
+ # v7.89.0 (council cH_r1 AC4): the helper now lives in autonomy/telemetry.sh as
117
+ # the SINGLE shared impl for both routes (the bash route in autonomy/loki calls
118
+ # the same function before its foreground egress, so the copy never drifts).
119
+ # Source it here at top level just for this helper -- telemetry.sh executes
120
+ # nothing on source beyond defining functions and two host/key vars, so this is
121
+ # side-effect-free for the shim. If the source fails for any reason, define a
122
+ # local fallback so on-by-default disclosure is never silently skipped.
123
+ if [ -f "$REPO_ROOT/autonomy/telemetry.sh" ]; then
124
+ # shellcheck source=../autonomy/telemetry.sh
125
+ source "$REPO_ROOT/autonomy/telemetry.sh" 2>/dev/null || true
126
+ fi
127
+ if ! declare -f _loki_disclose_telemetry_once >/dev/null 2>&1; then
128
+ _loki_disclose_telemetry_once() {
129
+ local _marker="${HOME}/.loki/.telemetry-disclosed"
130
+ [ -f "$_marker" ] 2>/dev/null && return 0
131
+ printf '%s\n' \
132
+ "Loki Mode sends anonymous diagnostics (os, arch, version, error type only --" \
133
+ "never your code, prompts, paths, or keys) to help fix bugs. Off in enterprise/" \
134
+ "CI/air-gapped setups. Turn it off anytime: loki telemetry off (docs/PRIVACY.md)" >&2
135
+ mkdir -p "${HOME}/.loki" 2>/dev/null || return 0
136
+ : > "$_marker" 2>/dev/null || true
137
+ return 0
138
+ }
139
+ fi
140
+
90
141
  # v7.4.13: one-shot first-run telemetry, MOVED from autonomy/loki main()
91
142
  # into the shim because the 8 ported commands (version, status, doctor,
92
143
  # stats, provider, memory) bypass main() entirely -- only unported
@@ -96,13 +147,26 @@ fi
96
147
  if [ -z "${LOKI_TELEMETRY_DISABLED:-}" ] && [ "${DO_NOT_TRACK:-}" != "1" ] && [ ! -f "${HOME}/.loki-first-run" ] 2>/dev/null; then
97
148
  touch "${HOME}/.loki-first-run" 2>/dev/null || true
98
149
  if command -v curl &>/dev/null && [ -f "$REPO_ROOT/autonomy/telemetry.sh" ]; then
99
- # Source telemetry helpers + fire installed event in background
100
- # so the user does not pay latency on first invocation.
101
- # Detach all three FDs so the backgrounded subshell never holds this
102
- # shim's stdout/stderr/stdin open (v7.8.3: same fix as the cli_command
103
- # emit below -- prevents a macOS broken-pipe in piped callers).
104
- ( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null && loki_telemetry "installed" "first_command=${1:-}" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
105
- disown 2>/dev/null || true
150
+ # Decide with the SAME gate the egress uses (_loki_telemetry_enabled), in a
151
+ # subshell so sourcing telemetry.sh cannot pollute the shim env.
152
+ # AC3: evaluate the GATE in the foreground (do NOT detach FDs here) so it
153
+ # inherits the real TTY / sees the exported LOKI_TTY_INTERACTIVE signal;
154
+ # only the network EMIT below is backgrounded with detached FDs. The
155
+ # sourced helper is silenced via 2>/dev/null and the gate writes nothing
156
+ # to stdout, so foreground evaluation adds no output.
157
+ if ( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null \
158
+ && declare -f _loki_telemetry_enabled >/dev/null 2>&1 \
159
+ && _loki_telemetry_enabled ); then
160
+ # Disclosure BEFORE the installed egress -- see the shared
161
+ # _loki_disclose_telemetry_once helper below (keyed on its OWN
162
+ # disclosed-marker, NOT .loki-first-run, so a first run in CI that
163
+ # suppressed disclosure does not permanently silence it).
164
+ _loki_disclose_telemetry_once
165
+ # Fire-and-forget the installed event; detach all FDs (v7.8.3 broken-pipe fix).
166
+ ( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null \
167
+ && loki_telemetry "installed" "first_command=${1:-}" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
168
+ disown 2>/dev/null || true
169
+ fi
106
170
  fi
107
171
  fi
108
172
 
@@ -176,6 +240,15 @@ if [ "${1:-}" = "report" ]; then
176
240
  done
177
241
  if [ "$_report_first_sub" = "kpis" ]; then
178
242
  if command -v curl &>/dev/null && [ -f "$REPO_ROOT/autonomy/telemetry.sh" ]; then
243
+ # Disclose once before egress if collection is genuinely enabled
244
+ # (covers the case where a cli_command is the first ENABLED egress,
245
+ # e.g. first run was CI/auto-off so .loki-first-run is already set).
246
+ # AC3: foreground gate evaluation (no FD detach) so the exported
247
+ # LOKI_TTY_INTERACTIVE signal / real TTY drives the verdict.
248
+ if ( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null \
249
+ && declare -f _loki_telemetry_enabled >/dev/null 2>&1 && _loki_telemetry_enabled ); then
250
+ _loki_disclose_telemetry_once
251
+ fi
179
252
  ( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null && loki_telemetry "cli_command" "command=${1:-}" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
180
253
  disown 2>/dev/null || true
181
254
  fi
@@ -187,7 +260,7 @@ fi
187
260
  # Two-token routes (provider show/list, memory list/index) match on the first
188
261
  # token only; the Bun dispatcher handles subcommand routing internally.
189
262
  case "${1:-}" in
190
- version|--version|-v|status|stats|doctor|provider|memory|rollback|internal|kpis|trust|proof|wiki|crash)
263
+ version|--version|-v|status|stats|doctor|provider|memory|rollback|internal|kpis|trust|proof|receipt|wiki|crash)
191
264
  # v7.5.2: rollback added (wires loki-ts/src/commands/rollback.ts).
192
265
  # v7.5.3: internal added for autonomy/run.sh phase1-hooks calls.
193
266
  # v7.5.28: kpis added (Phase K MVP: read-only KPI snapshot).
@@ -210,6 +283,14 @@ case "${1:-}" in
210
283
  # stdout/stderr/stdin open. Without this, the lingering FD changed
211
284
  # pipe-teardown timing and produced a macOS-only broken-pipe in the
212
285
  # shim-route CLI test harness (v7.8.2 -> fixed v7.8.3).
286
+ # Disclose once before egress if collection is genuinely enabled
287
+ # (first ENABLED egress may be a cli_command, not installed).
288
+ # AC3: foreground gate evaluation (no FD detach) so the exported
289
+ # LOKI_TTY_INTERACTIVE signal / real TTY drives the verdict.
290
+ if ( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null \
291
+ && declare -f _loki_telemetry_enabled >/dev/null 2>&1 && _loki_telemetry_enabled ); then
292
+ _loki_disclose_telemetry_once
293
+ fi
213
294
  ( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null && loki_telemetry "cli_command" "command=${1:-}" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
214
295
  disown 2>/dev/null || true
215
296
  fi
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.87.0"
10
+ __version__ = "7.89.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -10190,6 +10190,219 @@ async def get_proof_html(run_id: str):
10190
10190
  return FileResponse(str(index_html), media_type="text/html")
10191
10191
 
10192
10192
 
10193
+ # ---------------------------------------------------------------------------
10194
+ # Active spec + spec history.
10195
+ #
10196
+ # Gives the dashboard honest visibility into WHAT Loki is building from. The
10197
+ # resolution order mirrors proof-generator.py::_collect_spec so the panel and
10198
+ # the Evidence Receipt can never disagree about the spec source:
10199
+ # 1. PRD file -> session.json prdPath (only when the file still exists)
10200
+ # 2. one-line brief -> .loki/state/brief.txt
10201
+ # 3. generated PRD -> .loki/generated-prd.md
10202
+ # 4. no spec -> codebase-analysis
10203
+ # Issue mode is detected from the latest proof.json's spec.source (a GitHub
10204
+ # issue URL): issue runs re-dispatch through cmd_run, which synthesizes a PRD,
10205
+ # so at runtime they look like a generated-prd; the proof carries the real
10206
+ # issue ref. We never fabricate a type -- when nothing is resolvable we say so.
10207
+ #
10208
+ # History is derived from the proofs the Evidence Receipt already writes
10209
+ # (.loki/proofs/<run_id>/proof.json), newest-first. No new store is invented.
10210
+ # ---------------------------------------------------------------------------
10211
+ # Cap on the spec body returned to the dashboard so a huge PRD cannot bloat the
10212
+ # payload. The panel shows a preview; the full file lives on disk.
10213
+ _SPEC_CONTENT_CAP = 20000
10214
+ # Issue URLs (mirrors autonomy/loki's issue-mode detection regex). Matches a
10215
+ # tracker host followed somewhere by an /issue(s)/ path or a Jira /browse/ key.
10216
+ _ISSUE_URL_RE = re.compile(
10217
+ r"(github\.com|gitlab\.com|atlassian\.net|dev\.azure\.com|"
10218
+ r"visualstudio\.com)/.*\b(issues?|browse)/", re.IGNORECASE)
10219
+
10220
+
10221
+ def _spec_source_is_issue(source: str) -> bool:
10222
+ """True when a proof spec.source string is a tracker issue reference."""
10223
+ if not source:
10224
+ return False
10225
+ return bool(_ISSUE_URL_RE.search(source))
10226
+
10227
+
10228
+ def _latest_proof(proofs_dir: _Path) -> Optional[dict]:
10229
+ """Return the newest proof.json dict for the active project, or None."""
10230
+ try:
10231
+ entries = sorted(proofs_dir.iterdir())
10232
+ except (OSError, FileNotFoundError):
10233
+ return None
10234
+ newest = None
10235
+ newest_key = ""
10236
+ for entry in entries:
10237
+ if not entry.is_dir():
10238
+ continue
10239
+ proof_json = entry / "proof.json"
10240
+ if not proof_json.is_file():
10241
+ continue
10242
+ data = _safe_json_read(proof_json, default=None)
10243
+ if not isinstance(data, dict):
10244
+ continue
10245
+ key = data.get("generated_at") or entry.name
10246
+ if newest is None or key >= newest_key:
10247
+ newest = data
10248
+ newest_key = key
10249
+ return newest
10250
+
10251
+
10252
+ def _spec_summary(source: str, brief: str) -> str:
10253
+ """One-line summary for a history row, derived honestly from the spec.
10254
+
10255
+ Prefers the first non-empty line of the brief; falls back to the source
10256
+ basename. Never fabricates -- returns an honest label when nothing exists.
10257
+ """
10258
+ if brief:
10259
+ for line in brief.splitlines():
10260
+ line = line.strip().lstrip("#").strip()
10261
+ if line:
10262
+ return line[:160]
10263
+ if source in ("brief", "codebase-analysis", "", None):
10264
+ return {"brief": "one-line brief",
10265
+ "codebase-analysis": "Codebase analysis (no spec)"}.get(
10266
+ source, "Unknown spec")
10267
+ if _spec_source_is_issue(source):
10268
+ return source
10269
+ return os.path.basename(source) or source
10270
+
10271
+
10272
+ def _classify_proof_spec(spec: dict) -> str:
10273
+ """Map a proof.json spec dict to a dashboard spec type, honestly."""
10274
+ if not isinstance(spec, dict):
10275
+ return "unknown"
10276
+ source = (spec.get("source") or "").strip()
10277
+ if _spec_source_is_issue(source):
10278
+ return "issue"
10279
+ if source == "brief":
10280
+ return "brief"
10281
+ if source == "codebase-analysis":
10282
+ return "codebase-analysis"
10283
+ if not source:
10284
+ return "unknown"
10285
+ # A real filesystem path (PRD or generated PRD). A synthesized PRD lives at
10286
+ # .loki/generated-prd.md or .loki/brief-prd-*.md; anything else is a user
10287
+ # PRD. We report the broad "spec" type and the path so the UI can show it.
10288
+ return "spec"
10289
+
10290
+
10291
+ @app.get("/api/spec", dependencies=[Depends(auth.require_scope("read"))])
10292
+ async def get_active_spec():
10293
+ """Return the current run's spec source + content, honestly typed.
10294
+
10295
+ Types: prd | brief | spec | issue | codebase-analysis | none. The `none`
10296
+ state is the honest empty state when no run has produced a spec yet. We
10297
+ never invent a spec: each branch reads a real file or says it cannot.
10298
+ """
10299
+ loki_dir = _get_loki_dir()
10300
+
10301
+ # 1. PRD file recorded by the running orchestrator (session.json prdPath).
10302
+ # Only trust it when the file still exists on disk.
10303
+ session = _safe_json_read(loki_dir / "session.json", default=None)
10304
+ prd_path = ""
10305
+ if isinstance(session, dict):
10306
+ prd_path = (session.get("prdPath") or "").strip()
10307
+ if prd_path:
10308
+ p = _Path(prd_path)
10309
+ if p.is_file():
10310
+ content = _safe_read_text(p)
10311
+ return {
10312
+ "type": "prd",
10313
+ "path": str(p),
10314
+ "content": content[:_SPEC_CONTENT_CAP],
10315
+ "truncated": len(content) > _SPEC_CONTENT_CAP,
10316
+ }
10317
+
10318
+ # 2. one-line brief (zero-config first run). The raw brief is the strongest
10319
+ # honest artifact -- show it verbatim.
10320
+ brief_file = loki_dir / "state" / "brief.txt"
10321
+ if brief_file.is_file():
10322
+ text = _safe_read_text(brief_file).strip()
10323
+ if text:
10324
+ return {"type": "brief", "text": text[:_SPEC_CONTENT_CAP],
10325
+ "truncated": len(text) > _SPEC_CONTENT_CAP}
10326
+
10327
+ # 3. Issue mode: a brief/PRD run that originated from a tracker issue. The
10328
+ # runtime has only the synthesized PRD, but the latest proof.json carries
10329
+ # the real issue ref + brief. Surface it as an issue when we can prove it.
10330
+ latest = _latest_proof(loki_dir / "proofs")
10331
+ if isinstance(latest, dict):
10332
+ pspec = latest.get("spec")
10333
+ if isinstance(pspec, dict):
10334
+ src = (pspec.get("source") or "").strip()
10335
+ if _spec_source_is_issue(src):
10336
+ pbrief = (pspec.get("brief") or "").strip()
10337
+ return {
10338
+ "type": "issue",
10339
+ "ref": src,
10340
+ "title": _spec_summary(src, pbrief),
10341
+ "body": pbrief[:_SPEC_CONTENT_CAP],
10342
+ "truncated": len(pbrief) > _SPEC_CONTENT_CAP,
10343
+ }
10344
+
10345
+ # 4. Generated PRD (synthesized from a brief, or from codebase analysis).
10346
+ for name in ("generated-prd.md",):
10347
+ gen = loki_dir / name
10348
+ if gen.is_file():
10349
+ content = _safe_read_text(gen)
10350
+ if content.strip():
10351
+ return {
10352
+ "type": "spec",
10353
+ "path": str(gen),
10354
+ "content": content[:_SPEC_CONTENT_CAP],
10355
+ "truncated": len(content) > _SPEC_CONTENT_CAP,
10356
+ "generated": True,
10357
+ }
10358
+
10359
+ # 5. Codebase analysis (no explicit spec) -- only claim this when a run has
10360
+ # actually happened (a session or a proof exists). Otherwise honest none.
10361
+ has_session = isinstance(session, dict) and bool(session)
10362
+ if has_session or latest is not None:
10363
+ return {"type": "codebase-analysis"}
10364
+
10365
+ # 6. Nothing yet -- honest empty state, not a fabricated spec.
10366
+ return {"type": "none"}
10367
+
10368
+
10369
+ @app.get("/api/spec/history", dependencies=[Depends(auth.require_scope("read"))])
10370
+ async def get_spec_history():
10371
+ """Return past specs/issues/briefs for this codebase, newest-first.
10372
+
10373
+ Derived from the Evidence Receipts (.loki/proofs/<run_id>/proof.json) that
10374
+ every run already writes; no separate spec store is invented. Each row:
10375
+ {when, type, summary, run_id}. Missing/corrupt proofs are skipped, never
10376
+ faked. Empty -> {"history": []} (honest empty state).
10377
+ """
10378
+ proofs_dir = _get_loki_dir() / "proofs"
10379
+ rows: list[dict] = []
10380
+ try:
10381
+ entries = sorted(proofs_dir.iterdir())
10382
+ except (OSError, FileNotFoundError):
10383
+ return {"history": []}
10384
+ for entry in entries:
10385
+ if not entry.is_dir():
10386
+ continue
10387
+ proof_json = entry / "proof.json"
10388
+ if not proof_json.is_file():
10389
+ continue
10390
+ data = _safe_json_read(proof_json, default=None)
10391
+ if not isinstance(data, dict):
10392
+ continue
10393
+ spec = data.get("spec")
10394
+ spec = spec if isinstance(spec, dict) else {}
10395
+ source = (spec.get("source") or "").strip()
10396
+ rows.append({
10397
+ "run_id": data.get("run_id", entry.name),
10398
+ "when": data.get("generated_at"),
10399
+ "type": _classify_proof_spec(spec),
10400
+ "summary": _spec_summary(source, (spec.get("brief") or "").strip()),
10401
+ })
10402
+ rows.sort(key=lambda x: (x.get("when") or ""), reverse=True)
10403
+ return {"history": rows}
10404
+
10405
+
10193
10406
  # ---------------------------------------------------------------------------
10194
10407
  # R5: Auto-wiki + cited codebase Q&A (Loki's DeepWiki).
10195
10408
  #