loki-mode 7.18.1 → 7.18.2

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-to-product system. Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product via the RARV-C closure loop, with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.18.1
6
+ # Loki Mode v7.18.2
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -383,4 +383,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
383
383
 
384
384
  ---
385
385
 
386
- **v7.18.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
386
+ **v7.18.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.18.1
1
+ 7.18.2
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bash
2
+ # Loki Mode crash-reporting bash helpers (Phase 0: local-only, zero egress).
3
+ #
4
+ # This module is the single source of truth for the unified opt-out that gates
5
+ # BOTH PostHog usage telemetry and local crash capture. It is sourceable for
6
+ # helpers only; it executes nothing on source.
7
+ #
8
+ # Opt-out (any one disables collection):
9
+ # - LOKI_TELEMETRY=off (case-insensitive)
10
+ # - LOKI_TELEMETRY_DISABLED=true
11
+ # - DO_NOT_TRACK=1
12
+ # - ~/.loki/config line: TELEMETRY_DISABLED=true
13
+ #
14
+ # All capture is best-effort: it never blocks the parent and always returns 0.
15
+ # Phase 0 has zero network egress, so local capture is always safe and useful
16
+ # for the user to self-inspect what WOULD be sent.
17
+
18
+ # Double-source guard.
19
+ if [ -n "${_LOKI_CRASH_SH_SOURCED:-}" ]; then
20
+ return 0 2>/dev/null || true
21
+ fi
22
+ _LOKI_CRASH_SH_SOURCED=1
23
+
24
+ # Self-locate so we do not depend on the caller's SCRIPT_DIR / _LOKI_SCRIPT_DIR.
25
+ _LOKI_CRASH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ _LOKI_CRASH_CAPTURE_PY="${_LOKI_CRASH_DIR}/lib/crash_capture.py"
27
+
28
+ # loki_collection_enabled: returns 0 (enabled) unless disabled by any switch.
29
+ # This is the SINGLE source of truth for telemetry + crash collection state.
30
+ loki_collection_enabled() {
31
+ # Env: LOKI_TELEMETRY=off (case-insensitive)
32
+ local telem_lower
33
+ telem_lower="$(printf '%s' "${LOKI_TELEMETRY:-}" | tr '[:upper:]' '[:lower:]')"
34
+ [ "$telem_lower" = "off" ] && return 1
35
+
36
+ # Env: LOKI_TELEMETRY_DISABLED=true
37
+ [ "${LOKI_TELEMETRY_DISABLED:-}" = "true" ] && return 1
38
+
39
+ # Env: DO_NOT_TRACK=1 (community standard)
40
+ [ "${DO_NOT_TRACK:-}" = "1" ] && return 1
41
+
42
+ # Persistent opt-out in ~/.loki/config (matches autonomy/run.sh:643 format).
43
+ if [ -f "${HOME}/.loki/config" ] && grep -q "^TELEMETRY_DISABLED=true" "${HOME}/.loki/config" 2>/dev/null; then
44
+ return 1
45
+ fi
46
+
47
+ return 0
48
+ }
49
+
50
+ # _loki_crash_python: resolve a python3 interpreter, or return 1 if absent.
51
+ _loki_crash_python() {
52
+ if command -v python3 >/dev/null 2>&1; then
53
+ printf 'python3'
54
+ return 0
55
+ fi
56
+ return 1
57
+ }
58
+
59
+ # loki_crash_capture <error_class> <message> <stack> <rarv_phase> <exit_code>
60
+ # Best-effort local capture. Always returns 0. Never blocks the parent.
61
+ # Gated by the unified opt-out: if the user has opted out (LOKI_TELEMETRY=off /
62
+ # DO_NOT_TRACK=1 / loki telemetry off / config TELEMETRY_DISABLED=true), NO
63
+ # local crash file is written, matching the first-run disclosure and
64
+ # docs/PRIVACY.md promise that opt-out disables crash reporting.
65
+ loki_crash_capture() {
66
+ loki_collection_enabled || return 0
67
+
68
+ local error_class="${1:-UnknownError}"
69
+ local message="${2:-}"
70
+ local stack="${3:-}"
71
+ local rarv_phase="${4:-}"
72
+ local exit_code="${5:-}"
73
+
74
+ local py
75
+ py="$(_loki_crash_python)" || return 0
76
+ [ -f "$_LOKI_CRASH_CAPTURE_PY" ] || return 0
77
+
78
+ # Bound the stack so we never pass an oversized argv (E2BIG). The capture
79
+ # script reads the stack from stdin. Keep the last 200 lines, 16 KB cap.
80
+ local bounded_stack
81
+ bounded_stack="$(printf '%s' "$stack" | tail -c 16384 2>/dev/null)"
82
+
83
+ local args=(
84
+ "$_LOKI_CRASH_CAPTURE_PY"
85
+ --error-class "$error_class"
86
+ --message "$message"
87
+ --rarv-phase "$rarv_phase"
88
+ --exit-code "$exit_code"
89
+ --target-dir "${TARGET_DIR:-.}"
90
+ )
91
+
92
+ # Short timeout if available; stock macOS has no `timeout`, so run bare then.
93
+ if command -v timeout >/dev/null 2>&1; then
94
+ printf '%s' "$bounded_stack" | timeout 5 "$py" "${args[@]}" --stack - >/dev/null 2>&1 || true
95
+ else
96
+ printf '%s' "$bounded_stack" | "$py" "${args[@]}" --stack - >/dev/null 2>&1 || true
97
+ fi
98
+
99
+ return 0
100
+ }
101
+
102
+ # loki_crash_friction <friction_kind> <context>
103
+ # Conservative: caller decides when a real threshold is hit. Best-effort.
104
+ # Always returns 0. friction_kind is one of: retry_loop | rate_limit_loop |
105
+ # gate_failure. The context string is mapped into --message (the capture
106
+ # contract has no --context arg). Gated by the unified opt-out: if the user has
107
+ # opted out, NO local friction file is written either.
108
+ loki_crash_friction() {
109
+ loki_collection_enabled || return 0
110
+
111
+ local friction_kind="${1:-unknown}"
112
+ local context="${2:-}"
113
+
114
+ local py
115
+ py="$(_loki_crash_python)" || return 0
116
+ [ -f "$_LOKI_CRASH_CAPTURE_PY" ] || return 0
117
+
118
+ local args=(
119
+ "$_LOKI_CRASH_CAPTURE_PY"
120
+ --error-class "Friction"
121
+ --message "$context"
122
+ --friction-kind "$friction_kind"
123
+ --target-dir "${TARGET_DIR:-.}"
124
+ )
125
+
126
+ if command -v timeout >/dev/null 2>&1; then
127
+ timeout 5 "$py" "${args[@]}" </dev/null >/dev/null 2>&1 || true
128
+ else
129
+ "$py" "${args[@]}" </dev/null >/dev/null 2>&1 || true
130
+ fi
131
+
132
+ return 0
133
+ }
134
+
135
+ # loki_show_disclosure_once: print the first-run disclosure exactly once.
136
+ # Shown regardless of enabled/disabled state, before any egress. Uses the
137
+ # DISCLOSURE_SHOWN sentinel in ~/.loki/config (do not invent a new file).
138
+ # Safe if HOME is unwritable (best-effort).
139
+ loki_show_disclosure_once() {
140
+ local config="${HOME}/.loki/config"
141
+
142
+ # Already shown? Never re-show.
143
+ if [ -f "$config" ] && grep -q "^DISCLOSURE_SHOWN=true" "$config" 2>/dev/null; then
144
+ return 0
145
+ fi
146
+
147
+ # Disclosure copy (plan section 6a, verbatim; no emojis, no em dashes).
148
+ {
149
+ echo ""
150
+ echo "Loki Mode auto-creates the issues you hit at github.com/asklokesh/loki-mode"
151
+ echo "and tries to auto-resolve them. If it cannot, we encourage you to open an"
152
+ echo "issue for anything causing hesitation."
153
+ echo "We send anonymous diagnostics only (os, arch, version, error type, sanitized"
154
+ echo "stack signatures). Never your code, prompts, paths, keys, or repo names."
155
+ echo "See docs/PRIVACY.md. Turn this off anytime with: loki telemetry off"
156
+ echo ""
157
+ } >&2
158
+
159
+ # Persist the sentinel (best-effort; never fail the caller).
160
+ mkdir -p "${HOME}/.loki" 2>/dev/null || return 0
161
+ echo "DISCLOSURE_SHOWN=true" >> "$config" 2>/dev/null || true
162
+
163
+ return 0
164
+ }
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env python3
2
+ """Crash context capture for Loki Mode (Phase 0, local-only, no egress).
3
+
4
+ Builds the raw pre-scrub crash context, runs it through the shared scrubber
5
+ (crash_redact.scrub_and_whitelist), and writes the WHITELISTED payload to
6
+ .loki/crash/<fingerprint>-<unixts>.json. Never writes unscrubbed data. There is
7
+ no network egress in Phase 0.
8
+
9
+ FAIL CLOSED: if any step fails such that we cannot guarantee a scrubbed result,
10
+ exit nonzero and write nothing rather than risk a leak. If the scrubber returns
11
+ its safe minimal (ScrubError) shape we still write that record so we have a
12
+ trace, but it carries no raw data.
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import platform
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from datetime import datetime, timezone
24
+
25
+ # Make crash_redact importable regardless of cwd (same trick as
26
+ # proof-generator.py lines 32-37).
27
+ _HERE = os.path.dirname(os.path.abspath(__file__))
28
+ if _HERE not in sys.path:
29
+ sys.path.insert(0, _HERE)
30
+
31
+ import crash_redact # noqa: E402
32
+
33
+
34
+ def _read_loki_version():
35
+ """Read VERSION from the repo root (../../VERSION relative to this file).
36
+
37
+ Best-effort; returns None if unreadable.
38
+ """
39
+ try:
40
+ path = os.path.normpath(os.path.join(_HERE, "..", "..", "VERSION"))
41
+ with open(path, "r") as f:
42
+ v = f.read().strip()
43
+ return v or None
44
+ except Exception:
45
+ return None
46
+
47
+
48
+ def _discover_runtime_version(env_keys, cmd):
49
+ """Best-effort runtime version. Tries env keys first, then `<cmd> --version`.
50
+
51
+ Never raises; returns None on any failure.
52
+ """
53
+ for key in env_keys:
54
+ val = os.environ.get(key)
55
+ if val:
56
+ return val.strip()
57
+ try:
58
+ exe = shutil.which(cmd)
59
+ if not exe:
60
+ return None
61
+ proc = subprocess.run(
62
+ [exe, "--version"],
63
+ capture_output=True,
64
+ text=True,
65
+ timeout=3,
66
+ )
67
+ out = (proc.stdout or proc.stderr or "").strip()
68
+ return out.splitlines()[0].strip() if out else None
69
+ except Exception:
70
+ return None
71
+
72
+
73
+ def _discover_git_remote(cwd=None):
74
+ """Best-effort git remote origin URL via git config. Never raises."""
75
+ try:
76
+ target = cwd or os.getcwd()
77
+ exe = shutil.which("git")
78
+ if not exe:
79
+ return None
80
+ proc = subprocess.run(
81
+ [exe, "-C", target, "config", "--get", "remote.origin.url"],
82
+ capture_output=True,
83
+ text=True,
84
+ timeout=3,
85
+ )
86
+ out = (proc.stdout or "").strip()
87
+ return out or None
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ def _captured_at():
93
+ """UTC timestamp, second precision. Uses datetime.now(timezone.utc) (NOT the
94
+ banned utcnow())."""
95
+ try:
96
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
97
+ except Exception:
98
+ return None
99
+
100
+
101
+ def build_raw_context(
102
+ error_class,
103
+ message,
104
+ stack,
105
+ rarv_phase=None,
106
+ exit_code=None,
107
+ friction_kind=None,
108
+ ):
109
+ """Assemble the raw, pre-scrub crash context.
110
+
111
+ It is FINE for raw to contain unsafe data (message, full stack with paths,
112
+ git remote). The scrub step strips and whitelists everything next. The
113
+ returned dict is NEVER written to disk directly.
114
+ """
115
+ raw = {
116
+ "os": platform.system() or None,
117
+ "arch": platform.machine() or None,
118
+ "loki_version": _read_loki_version(),
119
+ "node_version": _discover_runtime_version(
120
+ ["LOKI_NODE_VERSION", "NODE_VERSION"], "node"
121
+ ),
122
+ "bun_version": _discover_runtime_version(
123
+ ["LOKI_BUN_VERSION", "BUN_VERSION"], "bun"
124
+ ),
125
+ "error_class": error_class,
126
+ "message": message,
127
+ # Raw frames; normalize_stack inside scrub reduces these to symbols.
128
+ "stack": stack if isinstance(stack, list) else (
129
+ stack.splitlines() if isinstance(stack, str) else []
130
+ ),
131
+ "rarv_phase": rarv_phase,
132
+ "exit_code": exit_code,
133
+ "friction_kind": friction_kind,
134
+ "captured_at": _captured_at(),
135
+ }
136
+ return raw
137
+
138
+
139
+ def capture(
140
+ error_class,
141
+ message,
142
+ stack,
143
+ rarv_phase=None,
144
+ exit_code=None,
145
+ friction_kind=None,
146
+ target_dir=None,
147
+ ):
148
+ """Build raw context, scrub, and write the whitelisted payload.
149
+
150
+ Writes to <target_dir or cwd>/.loki/crash/<fingerprint>-<unixts>.json.
151
+ Returns the written path, or None if the write itself failed.
152
+
153
+ NEVER writes unscrubbed data. If scrub returns the safe minimal (ScrubError)
154
+ shape, that minimal dict is still written (under a scruberror-<ts> name,
155
+ since it carries no fingerprint) so a trace exists with no leak.
156
+ """
157
+ raw = build_raw_context(
158
+ error_class=error_class,
159
+ message=message,
160
+ stack=stack,
161
+ rarv_phase=rarv_phase,
162
+ exit_code=exit_code,
163
+ friction_kind=friction_kind,
164
+ )
165
+
166
+ base = target_dir or os.getcwd()
167
+ home = os.environ.get("HOME")
168
+ repo_root = _detect_repo_root(base)
169
+ git_remote = _discover_git_remote(base)
170
+
171
+ scrubbed = crash_redact.scrub_and_whitelist(
172
+ raw,
173
+ home=home,
174
+ repo_root=repo_root,
175
+ git_remote=git_remote,
176
+ )
177
+
178
+ if not isinstance(scrubbed, dict):
179
+ # Defensive: scrub_and_whitelist always returns a dict, but never trust.
180
+ scrubbed = {
181
+ "error_class": "ScrubError",
182
+ "rules_version": crash_redact.CRASH_RULES_VERSION,
183
+ "redactions_count": 0,
184
+ }
185
+
186
+ ts = int(time.time())
187
+ fingerprint = scrubbed.get("fingerprint")
188
+ if isinstance(fingerprint, str) and fingerprint:
189
+ name = "{}-{}.json".format(fingerprint, ts)
190
+ else:
191
+ # ScrubError shape has no fingerprint; still write a trace.
192
+ name = "scruberror-{}.json".format(ts)
193
+
194
+ crash_dir = os.path.join(base, ".loki", "crash")
195
+ try:
196
+ os.makedirs(crash_dir, exist_ok=True)
197
+ out_path = os.path.join(crash_dir, name)
198
+ with open(out_path, "w") as f:
199
+ json.dump(scrubbed, f, indent=2, sort_keys=True)
200
+ return out_path
201
+ except Exception:
202
+ # Fail closed: if we cannot write the scrubbed file, write nothing.
203
+ return None
204
+
205
+
206
+ def _detect_repo_root(start):
207
+ """Best-effort: walk up from start to find a .git directory. Never raises."""
208
+ try:
209
+ cur = os.path.abspath(start)
210
+ while True:
211
+ if os.path.isdir(os.path.join(cur, ".git")):
212
+ return cur
213
+ parent = os.path.dirname(cur)
214
+ if parent == cur:
215
+ return None
216
+ cur = parent
217
+ except Exception:
218
+ return None
219
+
220
+
221
+ def _main():
222
+ parser = argparse.ArgumentParser(
223
+ description="Capture and scrub a Loki Mode crash report (local only)."
224
+ )
225
+ parser.add_argument("--error-class", default="UnknownError")
226
+ parser.add_argument("--message", default="")
227
+ parser.add_argument(
228
+ "--stack",
229
+ default=None,
230
+ help="Stack/traceback text. If omitted, read from stdin.",
231
+ )
232
+ parser.add_argument("--rarv-phase", default=None)
233
+ parser.add_argument("--exit-code", default=None)
234
+ parser.add_argument("--friction-kind", default=None)
235
+ parser.add_argument("--target-dir", default=None)
236
+ args = parser.parse_args()
237
+
238
+ stack_text = args.stack
239
+ # Read the stack from stdin when --stack is omitted (None) OR when it is the
240
+ # explicit "-" sentinel. The bash hook (autonomy/crash.sh) passes
241
+ # `--stack -` while piping the real stack to stdin; treating "-" as the
242
+ # stdin sentinel keeps the bash and TS routes producing the same
243
+ # stack_signature (and therefore the same fingerprint) for one crash.
244
+ if stack_text is None or stack_text == "-":
245
+ try:
246
+ if not sys.stdin.isatty():
247
+ stack_text = sys.stdin.read()
248
+ else:
249
+ stack_text = ""
250
+ except Exception:
251
+ stack_text = ""
252
+ stack_text = stack_text or ""
253
+
254
+ exit_code = args.exit_code
255
+ if exit_code is not None:
256
+ try:
257
+ exit_code = int(exit_code)
258
+ except (TypeError, ValueError):
259
+ # Keep as-is; scrub drops it unless whitelisted, and it is.
260
+ pass
261
+
262
+ path = capture(
263
+ error_class=args.error_class,
264
+ message=args.message,
265
+ stack=stack_text,
266
+ rarv_phase=args.rarv_phase,
267
+ exit_code=exit_code,
268
+ friction_kind=args.friction_kind,
269
+ target_dir=args.target_dir,
270
+ )
271
+
272
+ if path is None:
273
+ # Fail closed: nothing written, signal failure.
274
+ sys.exit(1)
275
+ print(path)
276
+ sys.exit(0)
277
+
278
+
279
+ if __name__ == "__main__":
280
+ try:
281
+ _main()
282
+ except SystemExit:
283
+ raise
284
+ except Exception:
285
+ # FAIL CLOSED: any unexpected failure exits nonzero, writes nothing.
286
+ sys.exit(1)