loki-mode 11.76.0 → 11.78.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/lib/check_author.py +146 -0
- package/autonomy/lib/claude-flags.sh +5 -1
- package/autonomy/lib/mcp-config.sh +41 -1
- package/autonomy/lib/proof-analytics-props.py +115 -6
- package/autonomy/lib/proof-verify.py +22 -0
- package/autonomy/lib/telemetry-schema.json +52 -0
- package/autonomy/loki +398 -7
- package/autonomy/telemetry.sh +70 -31
- package/completions/_loki +19 -0
- package/completions/loki.bash +17 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_start.py +2 -10
- package/dashboard/secure_tmp.py +92 -0
- package/dashboard/telemetry.py +81 -15
- package/docs/ARCHITECTURE.md +6 -4
- package/docs/{v10/CLI-MODERN.md → CLI-INVENTORY.md} +6 -6
- package/docs/CLI-REFERENCE.md +3 -2
- package/docs/COMPARISON.md +6 -8
- package/docs/COMPONENTS.md +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/PRIVACY.md +47 -11
- package/docs/V10-RESEARCH.md +1 -1
- package/docs/architecture/ADR-001-runtime-migration.md +7 -7
- package/docs/cursor-comparison.md +1 -1
- package/docs/dev/architecture-reference.md +7 -2
- package/docs/dev/project-structure.md +2 -1
- package/docs/image-provenance.md +32 -7
- package/docs/slack.md +2 -0
- package/docs/v11/COST-HALF.md +2 -1
- package/loki-ts/dist/loki.js +1744 -1604
- package/loki-ts/package.json +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/requirements.txt +1 -1
- package/package.json +3 -3
- package/packages/control-plane/dist/server.js +20045 -19119
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/aider.sh +7 -0
- package/providers/claude.sh +8 -1
- package/providers/cline.sh +3 -0
- package/providers/loader.sh +5 -0
- package/providers/model_catalog.json +3 -0
- package/providers/models.sh +59 -0
- package/providers/opencode.sh +9 -3
- package/skills/providers.md +1 -0
- package/web-app/dist/assets/{AdminPage-DoGPqZIa.js → AdminPage-BPF2Xehf.js} +1 -1
- package/web-app/dist/assets/{Avatar-Dyf7xJsz.js → Avatar-CSW9BhBU.js} +1 -1
- package/web-app/dist/assets/{Badge-Cm4dADew.js → Badge-wgm79wim.js} +1 -1
- package/web-app/dist/assets/{Button-LGxjldY5.js → Button-BcIAcPKr.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-DuC9g48J.js → CockpitPage-BFi-0Kwj.js} +1 -1
- package/web-app/dist/assets/{ComparePage-DuTrmedK.js → ComparePage-B7Wibrfn.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-B0f7NEiT.js → ErrorBoundary-BYyOVh2c.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-DsE8B9Hw.js → EvidenceReceiptPanel-C9QcrjNl.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-aYflOqd4.js → GitHubIssuesPanel-CWbuBxVZ.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CCJGck6c.js → GitHubPRsPanel-qcBHK615.js} +1 -1
- package/web-app/dist/assets/{HomePage-BJU3PVkJ.js → HomePage-B1mYFU4t.js} +2 -2
- package/web-app/dist/assets/{LoginPage-UzYoF5CJ.js → LoginPage-CmIO1xL7.js} +1 -1
- package/web-app/dist/assets/{MagicPage-5X3hQ7lt.js → MagicPage-L7UP4qKP.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-CFsTNyOu.js → MetricsPage-DVPCjN1U.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-Ba0nMlqG.js → NotFoundPage-D3XZmCH5.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-fvU-EYZb.js → ProjectPage-BlaMzlVi.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-CVEKCorG.js → ProjectsPage-DN7W28Zq.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-JGbsNmqD.js → SettingsPage-2u9ODYB5.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-Dr2c_V-y.js → ShowcasePage-CoKuTfab.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-D9dCc4Ir.js → SystemSettingsPage-Crhj2XAU.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-B56u-bod.js → TeamsPage-DehMgnKh.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CUwqj6ge.js → TemplatesPage-DGtwfZgU.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-Cm920SDi.js → TerminalOutput-7HZRtXoC.js} +1 -1
- package/web-app/dist/assets/{activity-DDF6juD-.js → activity-DAp5wswo.js} +1 -1
- package/web-app/dist/assets/{bell-7MW_oAcb.js → bell-Ncvnrz7d.js} +1 -1
- package/web-app/dist/assets/{bot-b63uz2Ov.js → bot-BWob3K3E.js} +1 -1
- package/web-app/dist/assets/{check-2oBMAALa.js → check-DReWA2vm.js} +1 -1
- package/web-app/dist/assets/{chevron-left-UuhI6pbU.js → chevron-left-CBJpzVU1.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BikRzQA7.js → circle-alert-2c_vs5PW.js} +1 -1
- package/web-app/dist/assets/{clock-BdkTaAgO.js → clock-BrS_ZKKi.js} +1 -1
- package/web-app/dist/assets/{cloud-oKg24kTO.js → cloud-Czvuo51R.js} +1 -1
- package/web-app/dist/assets/{code-xml-CpDmdnYQ.js → code-xml-BWEqJ2I8.js} +1 -1
- package/web-app/dist/assets/{database-BT133u2w.js → database-CJ6M-oK-.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-khUEgmXm.js → dollar-sign-DSObKmVv.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-_TlPqN5g.js → file-code-corner-CVEW-gF5.js} +1 -1
- package/web-app/dist/assets/{file-plus-B5UT0SZU.js → file-plus-CAK0I0tE.js} +1 -1
- package/web-app/dist/assets/{globe-Chhrgg2K.js → globe-DeUgcsSZ.js} +1 -1
- package/web-app/dist/assets/{hammer-DJ6Z4tiA.js → hammer-B74PyjS0.js} +1 -1
- package/web-app/dist/assets/{index-BprNVRFx.js → index-DW3_HRqp.js} +3 -3
- package/web-app/dist/assets/{layers-CtYWuenz.js → layers-Dw7kaGyJ.js} +1 -1
- package/web-app/dist/assets/{loader-circle-BRxnh_Va.js → loader-circle-B6xzlAjw.js} +1 -1
- package/web-app/dist/assets/{lock-btaZm2bN.js → lock-5ncCPzIG.js} +1 -1
- package/web-app/dist/assets/{package-BFU4d6gL.js → package-x59_dTx0.js} +1 -1
- package/web-app/dist/assets/{plus-BogXrZQi.js → plus-BcVBzjpS.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-CsdQkR5n.js → refresh-cw-CNrJhks6.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-BeLxChlE.js → rotate-ccw-BpmS_ezI.js} +1 -1
- package/web-app/dist/assets/{scroll-text-DAXSJc0i.js → scroll-text-CwBYNyoj.js} +1 -1
- package/web-app/dist/assets/{server-Cf_yJUqU.js → server-C4hnxkk6.js} +1 -1
- package/web-app/dist/assets/{shield-alert-B2gh1hM3.js → shield-alert-CSVUyWCM.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-DxYjJ1v8.js → thumbs-up-DEU9fmDX.js} +1 -1
- package/web-app/dist/assets/{trash-2-BaAsSOYk.js → trash-2-DJKMevAm.js} +1 -1
- package/web-app/dist/assets/{trending-down-Kc-Tady7.js → trending-down-Dq3wAM59.js} +1 -1
- package/web-app/dist/assets/{trending-up-BDsns895.js → trending-up-DribptN4.js} +1 -1
- package/web-app/dist/assets/{upload-CCJzLwO9.js → upload-DUlZxsH1.js} +1 -1
- package/web-app/dist/assets/{usePolling-4aqc_A7F.js → usePolling-PecqVZ0k.js} +1 -1
- package/web-app/dist/assets/{user-BzoHXLDe.js → user-BcLYLDVU.js} +1 -1
- package/web-app/dist/index.html +1 -1
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Runs an autonomous software factory that knows what it is supposed to deliver and proves it did, with a signed receipt. Use when the user says Loki Mode or asks to build, fix or verify software autonomously.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v11.
|
|
6
|
+
# Loki Mode v11.78.0
|
|
7
7
|
|
|
8
8
|
**Current entry points (Loki 10 engine):** `loki "<task>"`, `loki owner/repo#N` and `loki quick "<task>"` run the Loki 10 engine and end with a signed receipt (`loki verify`). `loki backlog owner/repo --all` runs many issues in parallel. Guide: `docs/v10/GUIDE.md`. The skill instructions below, and `loki start`, describe the previous bash loop. PRD-file starts (`loki start ./prd.md`) and the opencode provider still run on it until FC38-SWEEP ports them; the legacy-engine command and the LOKI_ENGINE switch remain removed. Prefer `loki owner/repo#N` for issues.
|
|
9
9
|
|
|
@@ -475,4 +475,4 @@ Implemented, planned and deprecated items, with the version each landed in: `ski
|
|
|
475
475
|
|
|
476
476
|
---
|
|
477
477
|
|
|
478
|
-
**v11.
|
|
478
|
+
**v11.78.0 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
11.
|
|
1
|
+
11.78.0
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check-author context (moat P3, interface A in tests/moat/p3-the-wall.sh).
|
|
3
|
+
|
|
4
|
+
check_author.py context --spec <file> --repo <dir>
|
|
5
|
+
|
|
6
|
+
Prints ONE JSON object on stdout: the exact and complete input handed to the
|
|
7
|
+
check-authoring model. No model call, no network, deterministic.
|
|
8
|
+
|
|
9
|
+
{"spec_sha256": "<hex>", "inputs": [{"path", "sha256"}...], "prompt": "<text>"}
|
|
10
|
+
|
|
11
|
+
Input selection mirrors the engine10 Wall (loki-ts/src/features/wall_manifest_wire.ts,
|
|
12
|
+
the manifest builder wallManifestFor) and is deliberately narrower: of the files
|
|
13
|
+
the Wall manifest reads, only the CONFIG class (runner and project config, same
|
|
14
|
+
regex as CONFIG there, applied anchored to the whole repo-relative path, so
|
|
15
|
+
only root-level config files qualify) is kept. Files come from git ls-files
|
|
16
|
+
(tracked regular files, no symlinks), never a filesystem walk, so untracked and
|
|
17
|
+
ignored files cannot reach the author. A non-git --repo fails closed: spec only.
|
|
18
|
+
Same MAX_BLOB size cap and control-character path rule as the Wall. The Wall's
|
|
19
|
+
other two classes are implementation-bearing and are excluded here: TESTISH test
|
|
20
|
+
sources (style examples) and "named" source files (signatures of files the task
|
|
21
|
+
names). Anything outside the spec and CONFIG files never reaches the author.
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
# Mirrors CONFIG, MAX_BLOB, MAX_FILES in wall_manifest_wire.ts.
|
|
32
|
+
CONFIG = re.compile(
|
|
33
|
+
r"^(package\.json|bunfig\.toml|(vitest|jest)\.config\.[cm]?[jt]s|pytest\.ini|"
|
|
34
|
+
r"pyproject\.toml|setup\.cfg|tox\.ini|go\.mod|Cargo\.toml)$"
|
|
35
|
+
)
|
|
36
|
+
MAX_BLOB = 200_000
|
|
37
|
+
MAX_FILES = 300
|
|
38
|
+
# Mirrors TESTISH in wall_manifest_wire.ts.
|
|
39
|
+
TESTISH = re.compile(r"(^|/)(tests?|__tests__|spec)/|\.(test|spec)\.[jt]sx?$|(^|/)test_[^/]*\.py$|_test\.(py|go)$")
|
|
40
|
+
CTRL = re.compile(r"[\x00-\x1f\x7f]")
|
|
41
|
+
|
|
42
|
+
PROMPT_HEAD = (
|
|
43
|
+
"You are the acceptance check author. You see only the specification and the "
|
|
44
|
+
"project's runner configuration below. You cannot see the implementation. "
|
|
45
|
+
"Write acceptance checks that prove the specification is met."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sha256(data: bytes) -> str:
|
|
50
|
+
return hashlib.sha256(data).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_bytes(path: str) -> bytes:
|
|
54
|
+
with open(path, "rb") as fh:
|
|
55
|
+
return fh.read()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def config_files(repo: str, spec_real: str):
|
|
59
|
+
found = []
|
|
60
|
+
for rel in tracked_regular_files(repo) or []:
|
|
61
|
+
# Anchored CONFIG against the whole repo-relative path, as the Wall does
|
|
62
|
+
# (CONFIG.test(e.path)): only root-level config files qualify.
|
|
63
|
+
if not CONFIG.match(rel) or TESTISH.search(rel) or CTRL.search(rel):
|
|
64
|
+
continue
|
|
65
|
+
full = os.path.join(repo, rel)
|
|
66
|
+
if os.path.islink(full) or not os.path.isfile(full) or os.path.realpath(full) == spec_real:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
if os.path.getsize(full) > MAX_BLOB:
|
|
70
|
+
continue
|
|
71
|
+
except OSError:
|
|
72
|
+
continue
|
|
73
|
+
found.append((rel, full))
|
|
74
|
+
if len(found) >= MAX_FILES:
|
|
75
|
+
break
|
|
76
|
+
return found
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def tracked_regular_files(repo: str):
|
|
80
|
+
"""git ls-files regular blobs (modes 100644/100755), like the Wall's ls-tree
|
|
81
|
+
read. None when repo is not a git repo or git fails: the caller fails closed."""
|
|
82
|
+
# Hardening mirrors SAFE_GIT_CONFIG in loki-ts/src/util/safe_git.ts: a repo-planted
|
|
83
|
+
# fsmonitor, hook or ssh command must never run, and a caller GIT_DIR must not
|
|
84
|
+
# redirect the listing.
|
|
85
|
+
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
|
|
86
|
+
env["GIT_CONFIG_NOSYSTEM"] = "1"
|
|
87
|
+
try:
|
|
88
|
+
r = subprocess.run(["git", "-c", "core.fsmonitor=", "-c", "core.hooksPath=/dev/null",
|
|
89
|
+
"-c", "core.sshCommand=", "-c", "protocol.ext.allow=never",
|
|
90
|
+
"-C", repo, "--no-replace-objects", "ls-files", "-s", "-z"],
|
|
91
|
+
capture_output=True, timeout=10, check=False, env=env)
|
|
92
|
+
except (OSError, subprocess.SubprocessError):
|
|
93
|
+
return None
|
|
94
|
+
if r.returncode != 0:
|
|
95
|
+
return None
|
|
96
|
+
out = []
|
|
97
|
+
for raw in r.stdout.split(b"\0"):
|
|
98
|
+
if not raw:
|
|
99
|
+
continue
|
|
100
|
+
meta, _, path = raw.partition(b"\t")
|
|
101
|
+
if meta.split(b" ")[0] not in (b"100644", b"100755"):
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
out.append(path.decode("utf-8"))
|
|
105
|
+
except UnicodeDecodeError:
|
|
106
|
+
continue
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_context(spec: str, repo: str) -> dict:
|
|
111
|
+
spec_bytes = read_bytes(spec)
|
|
112
|
+
spec_real = os.path.realpath(spec)
|
|
113
|
+
repo_real = os.path.realpath(repo)
|
|
114
|
+
if spec_real.startswith(repo_real + os.sep):
|
|
115
|
+
spec_path = os.path.relpath(spec_real, repo_real).replace(os.sep, "/")
|
|
116
|
+
else:
|
|
117
|
+
spec_path = spec_real
|
|
118
|
+
inputs = [{"path": spec_path, "sha256": sha256(spec_bytes)}]
|
|
119
|
+
parts = [PROMPT_HEAD, "Specification (%s):\n%s" % (spec_path, spec_bytes.decode("utf-8", "replace"))]
|
|
120
|
+
for rel, full in config_files(repo, spec_real):
|
|
121
|
+
data = read_bytes(full)
|
|
122
|
+
inputs.append({"path": rel, "sha256": sha256(data)})
|
|
123
|
+
parts.append("Runner config (%s):\n%s" % (rel, data.decode("utf-8", "replace")))
|
|
124
|
+
return {"spec_sha256": inputs[0]["sha256"], "inputs": inputs, "prompt": "\n\n".join(parts)}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv=None) -> int:
|
|
128
|
+
ap = argparse.ArgumentParser(prog="check_author.py")
|
|
129
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
130
|
+
c = sub.add_parser("context", help="print the exact check-author input as JSON")
|
|
131
|
+
c.add_argument("--spec", required=True)
|
|
132
|
+
c.add_argument("--repo", required=True)
|
|
133
|
+
args = ap.parse_args(argv)
|
|
134
|
+
if not os.path.isfile(args.spec):
|
|
135
|
+
print("check_author: spec not found: %s" % args.spec, file=sys.stderr)
|
|
136
|
+
return 2
|
|
137
|
+
if not os.path.isdir(args.repo):
|
|
138
|
+
print("check_author: repo not a directory: %s" % args.repo, file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
json.dump(build_context(args.spec, args.repo), sys.stdout, sort_keys=True)
|
|
141
|
+
sys.stdout.write("\n")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
sys.exit(main())
|
|
@@ -38,8 +38,12 @@ loki_effort_for_tier() {
|
|
|
38
38
|
cheap) effort="low" ;;
|
|
39
39
|
*) effort="high" ;;
|
|
40
40
|
esac
|
|
41
|
+
# LOKI_EFFORT is the one cross-provider knob: an explicit level replaces the tier-derived one
|
|
42
|
+
# (no complexity bump) and is still clamped to the model's catalog levels below.
|
|
43
|
+
local explicit=""
|
|
44
|
+
case "${LOKI_EFFORT:-}" in low|medium|high|xhigh|max) effort="$LOKI_EFFORT"; explicit=1 ;; esac
|
|
41
45
|
# Complex projects get one bump up. "max" is the ceiling and only for explicit user override (not auto-bumped to from xhigh).
|
|
42
|
-
if [ "$complexity" = "complex" ]; then
|
|
46
|
+
if [ "$complexity" = "complex" ] && [ -z "$explicit" ]; then
|
|
43
47
|
case "$effort" in
|
|
44
48
|
low) effort="medium" ;;
|
|
45
49
|
medium) effort="high" ;;
|
|
@@ -59,6 +59,37 @@ __LOKI_MCP_CONFIG_SH_LOADED=1
|
|
|
59
59
|
# stdio MCP server backed by `python3 -m mcp.server`. Caller may extend
|
|
60
60
|
# this in the future without API breakage; consumers should treat the
|
|
61
61
|
# bundle as opaque JSON.
|
|
62
|
+
# Remove stale temp files left by a writer killed between temp write and rename.
|
|
63
|
+
# Mirrors sweepStaleTmp in loki-ts/src/util/atomic.ts. A temp is
|
|
64
|
+
# removed only when it is named mcp-config.json.tmp.<pid>, is a regular file
|
|
65
|
+
# (not a symlink) in the same directory, is owned by the current uid, is not
|
|
66
|
+
# ours, and either its pid is dead or it is older than 60 minutes (pid reuse).
|
|
67
|
+
_loki_mcp_sweep_stale_tmp() {
|
|
68
|
+
local dir="$1" f base pid sig alive
|
|
69
|
+
# Iterate the glob, never find output: a filename holding a newline must
|
|
70
|
+
# stay ONE entry, not become a second cwd-relative path.
|
|
71
|
+
for f in "$dir"/mcp-config.json.tmp.*; do
|
|
72
|
+
[ -L "$f" ] && continue
|
|
73
|
+
[ -f "$f" ] && [ -O "$f" ] || continue
|
|
74
|
+
base="${f##*/}"
|
|
75
|
+
pid="${base#mcp-config.json.tmp.}"
|
|
76
|
+
case "$pid" in '' | *[!0-9]*) continue ;; esac
|
|
77
|
+
[ "$pid" = "$$" ] && continue
|
|
78
|
+
alive=0
|
|
79
|
+
if sig="$(LC_ALL=C kill -0 "$pid" 2>&1)"; then
|
|
80
|
+
alive=1
|
|
81
|
+
else
|
|
82
|
+
# EPERM means the process exists under another uid: alive, as in the TS route.
|
|
83
|
+
case "$sig" in *"not permitted"*) alive=1 ;; esac
|
|
84
|
+
fi
|
|
85
|
+
if [ "$alive" = 1 ]; then
|
|
86
|
+
# Live pid: keep unless older than 60 minutes.
|
|
87
|
+
[ -n "$(find "$f" -maxdepth 0 -mmin +60 2>/dev/null)" ] || continue
|
|
88
|
+
fi
|
|
89
|
+
rm -f -- "$f" 2>/dev/null
|
|
90
|
+
done
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
loki_mcp_config_path() {
|
|
63
94
|
local base="${TARGET_DIR:-.}"
|
|
64
95
|
local mcp_dir="${base}/.loki"
|
|
@@ -88,7 +119,9 @@ loki_mcp_config_path() {
|
|
|
88
119
|
# This makes the helper idempotent: identical bundle bytes -> no write,
|
|
89
120
|
# mtime stable. python3 is used so JSON encoding is canonical.
|
|
90
121
|
local tmp_bundle
|
|
122
|
+
_loki_mcp_sweep_stale_tmp "$mcp_dir"
|
|
91
123
|
tmp_bundle="${mcp_path}.tmp.$$"
|
|
124
|
+
rm -f -- "$tmp_bundle" 2>/dev/null # our pid left over from a reused pid
|
|
92
125
|
if ! _MCP_OUT="$tmp_bundle" _MCP_LSP="$lsp_detected" python3 -c "
|
|
93
126
|
import json, os
|
|
94
127
|
out = os.environ['_MCP_OUT']
|
|
@@ -105,13 +138,20 @@ if lsp:
|
|
|
105
138
|
'args': ['-m', 'mcp.lsp_proxy'],
|
|
106
139
|
}
|
|
107
140
|
bundle = {'mcpServers': servers}
|
|
108
|
-
|
|
141
|
+
# O_EXCL|O_NOFOLLOW, 0600: never write through a planted symlink or file.
|
|
142
|
+
fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0), 0o600)
|
|
143
|
+
with os.fdopen(fd, 'w') as f:
|
|
109
144
|
json.dump(bundle, f, indent=2)
|
|
110
145
|
" 2>/dev/null; then
|
|
111
146
|
rm -f "$tmp_bundle" 2>/dev/null
|
|
112
147
|
return 1
|
|
113
148
|
fi
|
|
114
149
|
|
|
150
|
+
# Test hook, test-mode only (LOKI_TEST): die between the temp write and the rename.
|
|
151
|
+
if [ -n "${LOKI_TEST:-}" ] && [ "${LOKI_TEST_DIE_AFTER_MCP_TMP:-}" = "1" ]; then
|
|
152
|
+
kill -9 $$
|
|
153
|
+
fi
|
|
154
|
+
|
|
115
155
|
# Idempotent write: only replace the file when bytes differ. cmp -s
|
|
116
156
|
# exits 0 when identical, so we keep the existing file in that case.
|
|
117
157
|
if [ -f "$mcp_path" ] && cmp -s "$tmp_bundle" "$mcp_path" 2>/dev/null; then
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
"""Emit the FIXED analytics allowlist from a finished proof.json as key=value
|
|
3
3
|
lines, one per line, for loki_emit_build_verified (autonomy/telemetry.sh).
|
|
4
4
|
|
|
5
|
-
This is the SINGLE authoritative allowlist for opt-in build-outcome analytics.
|
|
6
5
|
Only already-computed scalars are ever emitted -- never code, spec/PRD text,
|
|
7
6
|
paths, or file names. Anything not named here is never read, so a proof.json
|
|
8
7
|
schema change cannot silently start leaking a new field.
|
|
@@ -12,16 +11,124 @@ Free-text-shaped fields are excluded on purpose:
|
|
|
12
11
|
computed deterministically from facts -- never spec/project text.
|
|
13
12
|
- files_changed is emitted as a COUNT only, never the paths list.
|
|
14
13
|
|
|
14
|
+
The allowlist of keys, types, ranges and enums is NOT defined here. It lives in
|
|
15
|
+
autonomy/lib/telemetry-schema.json, the one schema shared with
|
|
16
|
+
autonomy/telemetry.sh and dashboard/telemetry.py. This file only maps proof.json
|
|
17
|
+
fields to schema keys and applies the schema (`sanitize`).
|
|
18
|
+
|
|
15
19
|
Usage: proof-analytics-props.py <proof.json>
|
|
20
|
+
proof-analytics-props.py --payload <event> <distinct_id> <api_key> [k=v ...]
|
|
21
|
+
prints the final capture body for the bash sender after schema
|
|
22
|
+
filtering; prints nothing and exits non-zero for an event the schema
|
|
23
|
+
does not list.
|
|
16
24
|
Fail-open: any error prints nothing and exits non-zero; the caller drops it.
|
|
17
25
|
"""
|
|
18
26
|
import json
|
|
27
|
+
import math
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
19
30
|
import sys
|
|
20
31
|
|
|
32
|
+
SCHEMA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "telemetry-schema.json")
|
|
33
|
+
_SEMVER = re.compile(r"^\d{1,6}\.\d{1,6}\.\d{1,6}(-[0-9A-Za-z.]{1,24})?$")
|
|
34
|
+
_INT = re.compile(r"^-?\d{1,12}$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_schema(path=None):
|
|
38
|
+
with open(path or SCHEMA_PATH) as f:
|
|
39
|
+
return json.load(f)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _coerce(spec, v):
|
|
43
|
+
"""Return (ok, value). A value that fails its spec is dropped, never forwarded."""
|
|
44
|
+
t = spec.get("type")
|
|
45
|
+
if t == "bool":
|
|
46
|
+
if isinstance(v, bool):
|
|
47
|
+
return True, v
|
|
48
|
+
if isinstance(v, str) and v in ("true", "false"):
|
|
49
|
+
return True, v == "true"
|
|
50
|
+
return False, None
|
|
51
|
+
if t == "enum":
|
|
52
|
+
if not isinstance(v, str):
|
|
53
|
+
return False, None
|
|
54
|
+
if spec.get("fold") == "upper":
|
|
55
|
+
v = v.upper()
|
|
56
|
+
return (v in spec["values"]), v
|
|
57
|
+
if t == "int":
|
|
58
|
+
if isinstance(v, bool):
|
|
59
|
+
return False, None
|
|
60
|
+
if isinstance(v, int):
|
|
61
|
+
n = v
|
|
62
|
+
elif isinstance(v, str) and _INT.match(v):
|
|
63
|
+
n = int(v)
|
|
64
|
+
else:
|
|
65
|
+
return False, None
|
|
66
|
+
return (spec["min"] <= n <= spec["max"]), n
|
|
67
|
+
if t == "number":
|
|
68
|
+
if isinstance(v, bool):
|
|
69
|
+
return False, None
|
|
70
|
+
if isinstance(v, str) and len(v) > 24:
|
|
71
|
+
return False, None
|
|
72
|
+
try:
|
|
73
|
+
n = float(v)
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return False, None
|
|
76
|
+
if not math.isfinite(n):
|
|
77
|
+
return False, None
|
|
78
|
+
return (spec["min"] <= n <= spec["max"]), n
|
|
79
|
+
if t == "semver":
|
|
80
|
+
return (isinstance(v, str) and bool(_SEMVER.match(v))), v
|
|
81
|
+
return False, None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sanitize(schema, event, props):
|
|
85
|
+
"""Return the filtered property dict, or None when the event is not listed."""
|
|
86
|
+
if not isinstance(event, str) or event not in schema["events"]:
|
|
87
|
+
return None
|
|
88
|
+
specs = dict(schema["common"])
|
|
89
|
+
specs.update(schema["events"][event])
|
|
90
|
+
out = {}
|
|
91
|
+
for k, v in (props or {}).items():
|
|
92
|
+
spec = specs.get(k) if isinstance(k, str) else None
|
|
93
|
+
if spec is None:
|
|
94
|
+
continue
|
|
95
|
+
ok, val = _coerce(spec, v)
|
|
96
|
+
if ok:
|
|
97
|
+
out[k] = val
|
|
98
|
+
# Every event carries the flag; events with no command source are False.
|
|
99
|
+
src = schema.get("introspective_source", {}).get(event)
|
|
100
|
+
out["introspective"] = bool(src) and out.get(src) in schema["introspective_commands"]
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def payload_main(argv):
|
|
105
|
+
"""argv: event distinct_id api_key [k=v ...]. Later pairs win, so the trusted
|
|
106
|
+
base properties the shell appends last cannot be overridden by a caller."""
|
|
107
|
+
if len(argv) < 3:
|
|
108
|
+
return 1
|
|
109
|
+
event, distinct_id, api_key = argv[0], argv[1], argv[2]
|
|
110
|
+
raw = {}
|
|
111
|
+
for arg in argv[3:]:
|
|
112
|
+
if "=" in arg:
|
|
113
|
+
k, v = arg.split("=", 1)
|
|
114
|
+
raw[k] = v
|
|
115
|
+
props = sanitize(load_schema(), event, raw)
|
|
116
|
+
if props is None:
|
|
117
|
+
return 1
|
|
118
|
+
sys.stdout.write(json.dumps({
|
|
119
|
+
"api_key": api_key,
|
|
120
|
+
"event": event,
|
|
121
|
+
"distinct_id": distinct_id,
|
|
122
|
+
"properties": props,
|
|
123
|
+
}))
|
|
124
|
+
return 0
|
|
125
|
+
|
|
21
126
|
|
|
22
127
|
def main():
|
|
23
128
|
if len(sys.argv) < 2:
|
|
24
129
|
return 1
|
|
130
|
+
if sys.argv[1] == "--payload":
|
|
131
|
+
return payload_main(sys.argv[2:])
|
|
25
132
|
try:
|
|
26
133
|
p = json.load(open(sys.argv[1]))
|
|
27
134
|
except Exception:
|
|
@@ -55,15 +162,17 @@ def main():
|
|
|
55
162
|
"gates_total": gates_total,
|
|
56
163
|
"gate_pass_rate": rate,
|
|
57
164
|
}
|
|
165
|
+
out = {k: v for k, v in out.items() if v is not None and v != ""}
|
|
166
|
+
out = sanitize(load_schema(), "build_verified", out) or {}
|
|
58
167
|
|
|
59
168
|
for k, v in out.items():
|
|
60
|
-
|
|
61
|
-
continue
|
|
62
|
-
# key=value; sanitize so a value can never inject a second pair or a newline.
|
|
63
|
-
v = str(v).replace("\n", " ").replace("=", "-")
|
|
169
|
+
# key=value; the schema already rejected anything with newline or "=".
|
|
64
170
|
sys.stdout.write("%s=%s\n" % (k, v))
|
|
65
171
|
return 0
|
|
66
172
|
|
|
67
173
|
|
|
68
174
|
if __name__ == "__main__":
|
|
69
|
-
|
|
175
|
+
try:
|
|
176
|
+
sys.exit(main())
|
|
177
|
+
except Exception:
|
|
178
|
+
sys.exit(1)
|
|
@@ -578,6 +578,24 @@ class ProofLoadError(Exception):
|
|
|
578
578
|
"""Raised for a missing file / malformed JSON / unusable proof shape."""
|
|
579
579
|
|
|
580
580
|
|
|
581
|
+
MAX_PROOF_DEPTH = 256 # mirrors MAX_RECEIPT_DEPTH in loki-ts verify_cmd.ts; real proofs are under 20 deep
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _too_deep(data):
|
|
585
|
+
"""Iterative nesting check, so every interpreter version gives the same answer."""
|
|
586
|
+
stack = [(data, 1)]
|
|
587
|
+
while stack:
|
|
588
|
+
node, depth = stack.pop()
|
|
589
|
+
if not isinstance(node, (dict, list)):
|
|
590
|
+
continue
|
|
591
|
+
if depth > MAX_PROOF_DEPTH:
|
|
592
|
+
return True
|
|
593
|
+
for child in (node.values() if isinstance(node, dict) else node):
|
|
594
|
+
if isinstance(child, (dict, list)):
|
|
595
|
+
stack.append((child, depth + 1))
|
|
596
|
+
return False
|
|
597
|
+
|
|
598
|
+
|
|
581
599
|
def _load_proof(proof_path):
|
|
582
600
|
if not os.path.isfile(proof_path):
|
|
583
601
|
raise ProofLoadError("proof file not found: %s" % proof_path)
|
|
@@ -586,10 +604,14 @@ def _load_proof(proof_path):
|
|
|
586
604
|
data = json.load(f)
|
|
587
605
|
except json.JSONDecodeError as exc:
|
|
588
606
|
raise ProofLoadError("malformed JSON in %s: %s" % (proof_path, exc))
|
|
607
|
+
except RecursionError:
|
|
608
|
+
raise ProofLoadError("proof nested too deeply: %s" % proof_path)
|
|
589
609
|
except OSError as exc:
|
|
590
610
|
raise ProofLoadError("could not read %s: %s" % (proof_path, exc))
|
|
591
611
|
if not isinstance(data, dict):
|
|
592
612
|
raise ProofLoadError("proof root is not a JSON object: %s" % proof_path)
|
|
613
|
+
if _too_deep(data):
|
|
614
|
+
raise ProofLoadError("proof nested too deeply: %s" % proof_path)
|
|
593
615
|
return data
|
|
594
616
|
|
|
595
617
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Single allowlist for every telemetry sender (autonomy/telemetry.sh, dashboard/telemetry.py, autonomy/lib/proof-analytics-props.py). An event not listed here is never sent. A key not listed is dropped. A value outside its type, range or enum is dropped. Documented in docs/PRIVACY.md.",
|
|
3
|
+
"schema_version": 1,
|
|
4
|
+
"common": {
|
|
5
|
+
"os_family": {"type": "enum", "values": ["linux", "darwin", "windows", "other"]},
|
|
6
|
+
"arch": {"type": "enum", "values": ["x64", "arm64", "other"]},
|
|
7
|
+
"version": {"type": "semver"},
|
|
8
|
+
"channel": {"type": "enum", "values": ["npm", "docker", "homebrew", "skill", "source", "unknown"]},
|
|
9
|
+
"traffic": {"type": "enum", "values": ["user", "ci", "internal"]},
|
|
10
|
+
"introspective": {"type": "bool"}
|
|
11
|
+
},
|
|
12
|
+
"introspective_commands": ["--help", "--version", "-h", "-v", "completion", "completions", "help", "internal", "memory", "version"],
|
|
13
|
+
"introspective_source": {"cli_command": "command", "installed": "first_command"},
|
|
14
|
+
"events": {
|
|
15
|
+
"installed": {
|
|
16
|
+
"first_command": {"type": "enum", "values": ["--help", "--version", "-h", "-v", "agent", "analyze", "api", "assets", "audit", "bench", "checkpoint", "ci", "cleanup", "cluster", "cockpit", "code", "completion", "completions", "compliance", "compound", "config", "context", "control", "cost", "council", "cp", "crash", "ctx", "dashboard", "demo", "deploy", "docker", "docs", "doctor", "dogfood", "enterprise", "explain", "export", "factory", "failover", "github", "grill", "handoff", "heal", "help", "import", "init", "internal", "issue", "kpis", "logs", "magic", "mcp", "memory", "metrics", "migrate", "modernize", "monitor", "next", "notify", "onboard", "open", "optimize", "otel", "own", "pause", "plan", "preview", "projects", "proof", "provider", "quick", "quickstart", "rc", "receipt", "remote", "report", "reset", "resume", "review", "rollback", "run", "sandbox", "secrets", "secure", "self_update", "self-update", "sentrux", "serve", "setup-skill", "share", "ship", "spec", "start", "state", "stats", "status", "steer", "stop", "syslog", "telemetry", "template", "test", "tour", "trigger", "trust", "trust-metrics", "ultracode", "update", "verify", "version", "voice", "watch", "watchdog", "web", "welcome", "why", "wiki", "worktree", "wow", "wt", "other"]}
|
|
17
|
+
},
|
|
18
|
+
"cli_command": {
|
|
19
|
+
"command": {"type": "enum", "values": ["--help", "--version", "-h", "-v", "agent", "analyze", "api", "assets", "audit", "bench", "checkpoint", "ci", "cleanup", "cluster", "cockpit", "code", "completion", "completions", "compliance", "compound", "config", "context", "control", "cost", "council", "cp", "crash", "ctx", "dashboard", "demo", "deploy", "docker", "docs", "doctor", "dogfood", "enterprise", "explain", "export", "factory", "failover", "github", "grill", "handoff", "heal", "help", "import", "init", "internal", "issue", "kpis", "logs", "magic", "mcp", "memory", "metrics", "migrate", "modernize", "monitor", "next", "notify", "onboard", "open", "optimize", "otel", "own", "pause", "plan", "preview", "projects", "proof", "provider", "quick", "quickstart", "rc", "receipt", "remote", "report", "reset", "resume", "review", "rollback", "run", "sandbox", "secrets", "secure", "self_update", "self-update", "sentrux", "serve", "setup-skill", "share", "ship", "spec", "start", "state", "stats", "status", "steer", "stop", "syslog", "telemetry", "template", "test", "tour", "trigger", "trust", "trust-metrics", "ultracode", "update", "verify", "version", "voice", "watch", "watchdog", "web", "welcome", "why", "wiki", "worktree", "wow", "wt", "other"]}
|
|
20
|
+
},
|
|
21
|
+
"session_start": {
|
|
22
|
+
"provider": {"type": "enum", "values": ["claude", "codex", "cline", "aider", "opencode"]},
|
|
23
|
+
"complexity": {"type": "enum", "values": ["simple", "standard", "complex"]},
|
|
24
|
+
"parallel": {"type": "bool"}
|
|
25
|
+
},
|
|
26
|
+
"session_end": {
|
|
27
|
+
"provider": {"type": "enum", "values": ["claude", "codex", "cline", "aider", "opencode"]},
|
|
28
|
+
"duration": {"type": "int", "min": 0, "max": 31536000},
|
|
29
|
+
"iterations": {"type": "int", "min": 0, "max": 100000},
|
|
30
|
+
"result": {"type": "int", "min": 0, "max": 255}
|
|
31
|
+
},
|
|
32
|
+
"dashboard_start": {},
|
|
33
|
+
"first_start_attempted": {
|
|
34
|
+
"entry": {"type": "enum", "values": ["--help", "--version", "-h", "-v", "agent", "analyze", "api", "assets", "audit", "bench", "checkpoint", "ci", "cleanup", "cluster", "cockpit", "code", "completion", "completions", "compliance", "compound", "config", "context", "control", "cost", "council", "cp", "crash", "ctx", "dashboard", "demo", "deploy", "docker", "docs", "doctor", "dogfood", "enterprise", "explain", "export", "factory", "failover", "github", "grill", "handoff", "heal", "help", "import", "init", "internal", "issue", "kpis", "logs", "magic", "mcp", "memory", "metrics", "migrate", "modernize", "monitor", "next", "notify", "onboard", "open", "optimize", "otel", "own", "pause", "plan", "preview", "projects", "proof", "provider", "quick", "quickstart", "rc", "receipt", "remote", "report", "reset", "resume", "review", "rollback", "run", "sandbox", "secrets", "secure", "self_update", "self-update", "sentrux", "serve", "setup-skill", "share", "ship", "spec", "start", "state", "stats", "status", "steer", "stop", "syslog", "telemetry", "template", "test", "tour", "trigger", "trust", "trust-metrics", "ultracode", "update", "verify", "version", "voice", "watch", "watchdog", "web", "welcome", "why", "wiki", "worktree", "wow", "wt", "other"]}
|
|
35
|
+
},
|
|
36
|
+
"first_run_blocked": {
|
|
37
|
+
"blocker": {"type": "enum", "values": ["no_provider", "not_logged_in", "not_a_repo", "git_identity", "node", "python3", "jq", "git", "curl", "disk", "skill_symlink", "other"]}
|
|
38
|
+
},
|
|
39
|
+
"build_verified": {
|
|
40
|
+
"headline": {"type": "enum", "values": ["VERIFIED", "VERIFIED WITH GAPS", "NOT VERIFIED"]},
|
|
41
|
+
"route": {"type": "enum", "values": ["engine10"]},
|
|
42
|
+
"evidence_gate_verdict": {"type": "enum", "fold": "upper", "values": ["PASS", "BLOCK"]},
|
|
43
|
+
"final_verdict": {"type": "enum", "fold": "upper", "values": ["APPROVED", "REJECTED", "APPROVE", "REJECT", "COMPLETE", "CONTINUE"]},
|
|
44
|
+
"iterations": {"type": "int", "min": 0, "max": 100000},
|
|
45
|
+
"files_changed_count": {"type": "int", "min": 0, "max": 10000000},
|
|
46
|
+
"wall_clock_sec": {"type": "number", "min": 0, "max": 31536000},
|
|
47
|
+
"gates_passed": {"type": "int", "min": 0, "max": 1000},
|
|
48
|
+
"gates_total": {"type": "int", "min": 0, "max": 1000},
|
|
49
|
+
"gate_pass_rate": {"type": "number", "min": 0, "max": 1}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|