loki-mode 7.129.4 → 8.0.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/README.md +81 -5
- package/SKILL.md +66 -5
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +37 -0
- package/autonomy/completion-council.sh +862 -28
- package/autonomy/council-v2.sh +39 -0
- package/autonomy/crash.sh +3 -2
- package/autonomy/grill.sh +42 -0
- package/autonomy/hooks/validate-bash.sh +301 -4
- package/autonomy/lib/cockpit-render.sh +8 -2
- package/autonomy/lib/cr-rematerialize.py +101 -4
- package/autonomy/lib/deadline.py +957 -0
- package/autonomy/lib/dependency-setup.sh +205 -0
- package/autonomy/lib/done-recognition.sh +45 -0
- package/autonomy/lib/efficiency_cost.py +13 -6
- package/autonomy/lib/no_mock_scan.py +793 -0
- package/autonomy/lib/prd-enrich.sh +42 -0
- package/autonomy/lib/proof-analytics-props.py +69 -0
- package/autonomy/lib/proof-generator.py +282 -95
- package/autonomy/lib/proof-template.html +15 -12
- package/autonomy/lib/proof-verify.py +151 -102
- package/autonomy/lib/requirements_contract.py +848 -0
- package/autonomy/lib/sdk-mode.sh +103 -0
- package/autonomy/lib/secret-scan.sh +139 -0
- package/autonomy/lib/spec-expand.sh +208 -0
- package/autonomy/lib/tree_digest.py +266 -0
- package/autonomy/lib/voter-agents.sh +58 -8
- package/autonomy/lib/workspace_diff.py +124 -0
- package/autonomy/loki +189 -17
- package/autonomy/playwright-verify.sh +501 -0
- package/autonomy/prd-checklist.sh +17 -8
- package/autonomy/provider-offer.sh +111 -0
- package/autonomy/run.sh +4424 -678
- package/autonomy/sandbox.sh +42 -0
- package/autonomy/spec-interrogation.sh +148 -5
- package/autonomy/spec.sh +118 -1
- package/autonomy/telemetry.sh +133 -7
- package/autonomy/verify.sh +107 -0
- package/bin/loki +110 -3
- package/completions/_loki +10 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +96 -7
- package/dashboard/build_supervisor.py +1619 -0
- package/dashboard/control.py +8 -0
- package/dashboard/prompt_optimizer.py +7 -0
- package/dashboard/server.py +577 -22
- package/dashboard/static/trust.html +1 -1
- package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
- package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
- package/docs/INSTALLATION.md +19 -2
- package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
- package/docs/PLANS-INDEX.md +33 -0
- package/docs/PRIVACY.md +29 -1
- package/docs/SIGNED-RECEIPTS.md +102 -0
- package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
- package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
- package/docs/V8-AGENT-SDK-PLAN.md +692 -0
- package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
- package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
- package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
- package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
- package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
- package/docs/alternative-installations.md +4 -4
- package/loki-ts/dist/loki.js +674 -285
- package/loki-ts/package.json +2 -0
- package/mcp/__init__.py +1 -1
- package/package.json +7 -6
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +75 -0
- package/providers/codex.sh +85 -13
- package/references/sdk-mode.md +106 -0
- package/skills/model-selection.md +1 -1
- package/skills/quality-gates.md +22 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic digest of the source tree verified by a Loki proof."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
MANIFEST_VERSION = 3
|
|
16
|
+
_EXCLUDED_TOP_LEVEL = {".git", ".loki"}
|
|
17
|
+
_EXCLUDED_TOP_LEVEL_GENERATED = {".next", ".nuxt", "target"}
|
|
18
|
+
_EXCLUDED_WALK_DIRS = {
|
|
19
|
+
".git",
|
|
20
|
+
".mypy_cache",
|
|
21
|
+
".pytest_cache",
|
|
22
|
+
".venv",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
"node_modules",
|
|
25
|
+
"venv",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sha256_file(path: Path) -> str:
|
|
30
|
+
digest = hashlib.sha256()
|
|
31
|
+
with path.open("rb") as handle:
|
|
32
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
33
|
+
digest.update(chunk)
|
|
34
|
+
return digest.hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _git_paths(root: Path) -> list[str] | None:
|
|
38
|
+
"""Return tracked paths, or None when root is not the repository root."""
|
|
39
|
+
try:
|
|
40
|
+
inside = subprocess.run(
|
|
41
|
+
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
timeout=10,
|
|
44
|
+
)
|
|
45
|
+
if inside.returncode != 0 or inside.stdout.strip() != b"true":
|
|
46
|
+
return None
|
|
47
|
+
top = subprocess.run(
|
|
48
|
+
["git", "-C", str(root), "rev-parse", "--show-toplevel"],
|
|
49
|
+
capture_output=True,
|
|
50
|
+
timeout=10,
|
|
51
|
+
)
|
|
52
|
+
if top.returncode != 0:
|
|
53
|
+
return None
|
|
54
|
+
top_path = Path(os.fsdecode(top.stdout.rstrip(b"\n"))).resolve()
|
|
55
|
+
if top_path != root.resolve():
|
|
56
|
+
return None
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
[
|
|
59
|
+
"git",
|
|
60
|
+
"-C",
|
|
61
|
+
str(root),
|
|
62
|
+
"ls-files",
|
|
63
|
+
"-z",
|
|
64
|
+
"--cached",
|
|
65
|
+
],
|
|
66
|
+
capture_output=True,
|
|
67
|
+
timeout=30,
|
|
68
|
+
)
|
|
69
|
+
except (OSError, subprocess.SubprocessError):
|
|
70
|
+
return None
|
|
71
|
+
if result.returncode != 0:
|
|
72
|
+
return None
|
|
73
|
+
return [
|
|
74
|
+
raw.decode("utf-8", errors="surrogateescape")
|
|
75
|
+
for raw in result.stdout.split(b"\0")
|
|
76
|
+
if raw
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _filesystem_paths(root: Path, gitlinks: set[str]) -> list[str]:
|
|
81
|
+
"""Enumerate nondependency worktree bytes without consulting Git ignores."""
|
|
82
|
+
paths: list[str] = []
|
|
83
|
+
|
|
84
|
+
def visit(directory: Path, prefix: str) -> None:
|
|
85
|
+
try:
|
|
86
|
+
entries = sorted(os.scandir(directory), key=lambda item: item.name)
|
|
87
|
+
except OSError:
|
|
88
|
+
return
|
|
89
|
+
for entry in entries:
|
|
90
|
+
relative = f"{prefix}/{entry.name}" if prefix else entry.name
|
|
91
|
+
normalized = relative.replace(os.sep, "/")
|
|
92
|
+
if not prefix and entry.name in (
|
|
93
|
+
_EXCLUDED_TOP_LEVEL | _EXCLUDED_TOP_LEVEL_GENERATED
|
|
94
|
+
):
|
|
95
|
+
continue
|
|
96
|
+
if normalized in gitlinks:
|
|
97
|
+
paths.append(relative)
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
is_directory = entry.is_dir(follow_symlinks=False)
|
|
101
|
+
except OSError:
|
|
102
|
+
paths.append(relative)
|
|
103
|
+
continue
|
|
104
|
+
if is_directory:
|
|
105
|
+
if entry.name in _EXCLUDED_WALK_DIRS:
|
|
106
|
+
continue
|
|
107
|
+
visit(Path(entry.path), relative)
|
|
108
|
+
else:
|
|
109
|
+
paths.append(relative)
|
|
110
|
+
|
|
111
|
+
visit(root, "")
|
|
112
|
+
return paths
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _gitlinks(root: Path) -> dict[str, list[dict[str, str]]]:
|
|
116
|
+
"""Return staged gitlink identities keyed by their repository path."""
|
|
117
|
+
try:
|
|
118
|
+
result = subprocess.run(
|
|
119
|
+
["git", "-C", str(root), "ls-files", "--stage", "-z"],
|
|
120
|
+
capture_output=True,
|
|
121
|
+
timeout=30,
|
|
122
|
+
)
|
|
123
|
+
except (OSError, subprocess.SubprocessError):
|
|
124
|
+
return {}
|
|
125
|
+
if result.returncode != 0:
|
|
126
|
+
return {}
|
|
127
|
+
links: dict[str, list[dict[str, str]]] = {}
|
|
128
|
+
for record in result.stdout.split(b"\0"):
|
|
129
|
+
if not record or b"\t" not in record:
|
|
130
|
+
continue
|
|
131
|
+
metadata, raw_path = record.split(b"\t", 1)
|
|
132
|
+
fields = metadata.split()
|
|
133
|
+
if len(fields) != 3 or fields[0] != b"160000":
|
|
134
|
+
continue
|
|
135
|
+
relative = raw_path.decode("utf-8", errors="surrogateescape")
|
|
136
|
+
links.setdefault(relative, []).append(
|
|
137
|
+
{
|
|
138
|
+
"stage": fields[2].decode("ascii", errors="replace"),
|
|
139
|
+
"oid": fields[1].decode("ascii", errors="replace"),
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
for values in links.values():
|
|
143
|
+
values.sort(key=lambda item: (item["stage"], item["oid"]))
|
|
144
|
+
return links
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _gitlink_entry(
|
|
148
|
+
root: Path, relative: str, index_entries: list[dict[str, str]]
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Bind both the staged gitlink and the checked-out submodule contents."""
|
|
151
|
+
normalized = relative.replace(os.sep, "/")
|
|
152
|
+
path = root / relative
|
|
153
|
+
head_oid = ""
|
|
154
|
+
try:
|
|
155
|
+
head = subprocess.run(
|
|
156
|
+
["git", "-C", str(path), "rev-parse", "--verify", "HEAD"],
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
timeout=10,
|
|
160
|
+
)
|
|
161
|
+
if head.returncode == 0:
|
|
162
|
+
head_oid = (head.stdout or "").strip()
|
|
163
|
+
except (OSError, subprocess.SubprocessError):
|
|
164
|
+
pass
|
|
165
|
+
return {
|
|
166
|
+
"path": normalized,
|
|
167
|
+
"kind": "gitlink",
|
|
168
|
+
"index": index_entries,
|
|
169
|
+
"head_oid": head_oid,
|
|
170
|
+
"worktree_sha256": compute_tree_digest(path),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _entry(
|
|
175
|
+
root: Path,
|
|
176
|
+
relative: str,
|
|
177
|
+
gitlink_entries: list[dict[str, str]] | None = None,
|
|
178
|
+
) -> dict[str, Any] | None:
|
|
179
|
+
normalized = relative.replace(os.sep, "/")
|
|
180
|
+
top = normalized.split("/", 1)[0]
|
|
181
|
+
if top in _EXCLUDED_TOP_LEVEL:
|
|
182
|
+
return None
|
|
183
|
+
if gitlink_entries is not None:
|
|
184
|
+
return _gitlink_entry(root, relative, gitlink_entries)
|
|
185
|
+
|
|
186
|
+
path = root / relative
|
|
187
|
+
try:
|
|
188
|
+
info = path.lstat()
|
|
189
|
+
except FileNotFoundError:
|
|
190
|
+
return {"path": normalized, "kind": "missing"}
|
|
191
|
+
except OSError:
|
|
192
|
+
return {"path": normalized, "kind": "unreadable"}
|
|
193
|
+
|
|
194
|
+
executable = bool(info.st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH))
|
|
195
|
+
if stat.S_ISLNK(info.st_mode):
|
|
196
|
+
try:
|
|
197
|
+
target = os.readlink(path)
|
|
198
|
+
except OSError:
|
|
199
|
+
target = ""
|
|
200
|
+
target_bytes = os.fsencode(target)
|
|
201
|
+
return {
|
|
202
|
+
"path": normalized,
|
|
203
|
+
"kind": "symlink",
|
|
204
|
+
"executable": executable,
|
|
205
|
+
"size": len(target_bytes),
|
|
206
|
+
"sha256": hashlib.sha256(target_bytes).hexdigest(),
|
|
207
|
+
}
|
|
208
|
+
if stat.S_ISREG(info.st_mode):
|
|
209
|
+
try:
|
|
210
|
+
content_hash = _sha256_file(path)
|
|
211
|
+
except OSError:
|
|
212
|
+
return {"path": normalized, "kind": "unreadable"}
|
|
213
|
+
return {
|
|
214
|
+
"path": normalized,
|
|
215
|
+
"kind": "file",
|
|
216
|
+
"executable": executable,
|
|
217
|
+
"size": info.st_size,
|
|
218
|
+
"sha256": content_hash,
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
"path": normalized,
|
|
222
|
+
"kind": "other",
|
|
223
|
+
"executable": executable,
|
|
224
|
+
"size": info.st_size,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def build_manifest(root: str | os.PathLike[str]) -> dict[str, Any] | None:
|
|
229
|
+
"""Build the canonical Git worktree manifest used by proof binding."""
|
|
230
|
+
resolved = Path(root).resolve()
|
|
231
|
+
tracked_paths = _git_paths(resolved)
|
|
232
|
+
if tracked_paths is None:
|
|
233
|
+
return None
|
|
234
|
+
gitlinks = _gitlinks(resolved)
|
|
235
|
+
paths = set(tracked_paths)
|
|
236
|
+
paths.update(_filesystem_paths(resolved, set(gitlinks)))
|
|
237
|
+
entries = []
|
|
238
|
+
for relative in sorted(paths):
|
|
239
|
+
item = _entry(resolved, relative, gitlinks.get(relative))
|
|
240
|
+
if item is not None:
|
|
241
|
+
entries.append(item)
|
|
242
|
+
return {
|
|
243
|
+
"version": MANIFEST_VERSION,
|
|
244
|
+
"filesystem_walk_excluded_top_level_directories": sorted(
|
|
245
|
+
_EXCLUDED_TOP_LEVEL | _EXCLUDED_TOP_LEVEL_GENERATED
|
|
246
|
+
),
|
|
247
|
+
"excluded_walk_directories": sorted(_EXCLUDED_WALK_DIRS),
|
|
248
|
+
"entries": entries,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def compute_tree_digest(root: str | os.PathLike[str]) -> str:
|
|
253
|
+
"""Return a SHA-256 digest, or an empty string when it cannot be derived."""
|
|
254
|
+
manifest = build_manifest(root)
|
|
255
|
+
if manifest is None:
|
|
256
|
+
return ""
|
|
257
|
+
encoded = json.dumps(
|
|
258
|
+
manifest,
|
|
259
|
+
sort_keys=True,
|
|
260
|
+
separators=(",", ":"),
|
|
261
|
+
ensure_ascii=True,
|
|
262
|
+
).encode("utf-8")
|
|
263
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
__all__ = ["MANIFEST_VERSION", "build_manifest", "compute_tree_digest"]
|
|
@@ -246,20 +246,66 @@ loki_council_dispatch_agents() {
|
|
|
246
246
|
schema_content=$(cat "$schema_path" 2>/dev/null) || return 1
|
|
247
247
|
[ -n "$schema_content" ] || return 1
|
|
248
248
|
|
|
249
|
-
# 3. Invoke claude. Guard against absent binary or non-zero exit.
|
|
250
|
-
command -v claude >/dev/null 2>&1 || return 1
|
|
251
|
-
|
|
252
249
|
local prompt
|
|
253
250
|
prompt=$(printf 'Loki council iteration %s. Run each declared agent against the current workspace, return one finding per agent matching the provided JSON Schema. Be terse, be honest.' "$iteration")
|
|
254
251
|
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
# LOW finding: stored under COUNCIL_STATE_DIR, no PII risk beyond the
|
|
258
|
-
# prompt itself which already lives in the dispatch log.
|
|
259
|
-
local response
|
|
252
|
+
# Shared dispatch state (declared once, before either the SDK or claude arm).
|
|
253
|
+
local response=""
|
|
260
254
|
local rc=0
|
|
261
255
|
local stderr_log="$COUNCIL_STATE_DIR/votes/dispatch-stderr-${iteration}.log"
|
|
262
256
|
mkdir -p "$(dirname "$stderr_log")" 2>/dev/null || true
|
|
257
|
+
local _va_sdk_done=0
|
|
258
|
+
|
|
259
|
+
# v8 RAW-SDK COUNCIL PATH (opt-in LOKI_SDK_VOTER_AGENTS=1). The bash route
|
|
260
|
+
# uses `claude --agents <json> --json-schema` (one dispatch fanning out to N
|
|
261
|
+
# named agents). The raw @anthropic-ai/sdk has no --agents primitive, so we
|
|
262
|
+
# inline the agent roster into the prompt and constrain the SAME
|
|
263
|
+
# finding-schema.json via the sdk-judge bridge -- the model returns the same
|
|
264
|
+
# {findings:[...]} object the downstream parser (line 302+) consumes, one
|
|
265
|
+
# finding per declared agent. Runs BEFORE the claude-binary guard so the
|
|
266
|
+
# no-binary deploy win holds. Fail-closed: on ANY miss (flag off, no key,
|
|
267
|
+
# bun/entrypoint absent, non-zero, empty) fall through to the claude path
|
|
268
|
+
# below, then to the heuristic council on a claude miss -- a hung/failed
|
|
269
|
+
# council can never become a false COMPLETE (same guarantee as the claude arm).
|
|
270
|
+
if [ "${LOKI_SDK_VOTER_AGENTS:-0}" = "1" ]; then
|
|
271
|
+
local _va_root _va_loki _va_schema_f _va_pf _va_out _va_rc
|
|
272
|
+
_va_root="$(cd "${__LOKI_VA_REPO_ROOT}" 2>/dev/null && pwd)" || _va_root="${__LOKI_VA_REPO_ROOT}"
|
|
273
|
+
_va_loki="${_va_root}/bin/loki"
|
|
274
|
+
_va_schema_f="${_va_root}/loki-ts/data/finding-schema.json"
|
|
275
|
+
if [ -x "$_va_loki" ] && [ -f "$_va_schema_f" ] && command -v bun >/dev/null 2>&1; then
|
|
276
|
+
_va_pf="$(mktemp 2>/dev/null)" || _va_pf=""
|
|
277
|
+
if [ -n "$_va_pf" ]; then
|
|
278
|
+
# inline the agent roster so a single schema-constrained call
|
|
279
|
+
# produces one finding per declared council agent.
|
|
280
|
+
printf '%s\n\nDeclared council agents (produce exactly one finding per agent, in this order):\n%s\n' \
|
|
281
|
+
"$prompt" "$agents_json" > "$_va_pf"
|
|
282
|
+
_va_rc=0
|
|
283
|
+
local _va_to_s="${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}"
|
|
284
|
+
local _va_wrap
|
|
285
|
+
if command -v timeout >/dev/null 2>&1; then _va_wrap="timeout $(( _va_to_s + 15 ))"
|
|
286
|
+
elif command -v gtimeout >/dev/null 2>&1; then _va_wrap="gtimeout $(( _va_to_s + 15 ))"
|
|
287
|
+
else _va_wrap=""; fi
|
|
288
|
+
_va_out="$($_va_wrap "$_va_loki" internal sdk-judge \
|
|
289
|
+
--prompt-file "$_va_pf" --schema-file "$_va_schema_f" \
|
|
290
|
+
--model "${LOKI_SDK_COUNCIL_MODEL:-claude-sonnet-5}" --effort high \
|
|
291
|
+
--timeout-ms "$(( _va_to_s * 1000 ))" 2>"$COUNCIL_STATE_DIR/votes/dispatch-stderr-${iteration}.log")" || _va_rc=$?
|
|
292
|
+
rm -f "$_va_pf" 2>/dev/null || true
|
|
293
|
+
if [ "$_va_rc" -eq 0 ] && [ -n "$_va_out" ]; then
|
|
294
|
+
# feed the SDK object into the SAME parse+materialize path
|
|
295
|
+
response="$_va_out"
|
|
296
|
+
rc=0
|
|
297
|
+
_va_sdk_done=1 # skip the claude dispatch below
|
|
298
|
+
fi
|
|
299
|
+
fi
|
|
300
|
+
fi
|
|
301
|
+
# fall through to the claude path (fail-closed) if not _va_sdk_done
|
|
302
|
+
fi
|
|
303
|
+
|
|
304
|
+
# 3. Invoke claude (only when the SDK path did not already produce a response).
|
|
305
|
+
# Guard against absent binary or non-zero exit.
|
|
306
|
+
if [ "$_va_sdk_done" != "1" ]; then
|
|
307
|
+
command -v claude >/dev/null 2>&1 || return 1
|
|
308
|
+
|
|
263
309
|
# caveman HARD-SUPPRESS (parsed output, v7.41.0): the response is parsed for
|
|
264
310
|
# findings[].vote against the JSON Schema. A globally-active caveman would
|
|
265
311
|
# compress/reword it and break the schema match or flip a vote. The tree-wide
|
|
@@ -285,6 +331,10 @@ loki_council_dispatch_agents() {
|
|
|
285
331
|
-p "$prompt" \
|
|
286
332
|
--agents "$agents_json" \
|
|
287
333
|
--json-schema "$schema_content" 2>"$stderr_log") || rc=$?
|
|
334
|
+
fi # end claude-dispatch guard (SDK path skips it, already has $response)
|
|
335
|
+
|
|
336
|
+
# Shared fail-closed check for BOTH paths: any non-zero exit (claude/SDK
|
|
337
|
+
# timeout 124 or failure) or empty response -> return 1 -> heuristic fallback.
|
|
288
338
|
if [ "$rc" -ne 0 ] || [ -z "$response" ]; then
|
|
289
339
|
return 1
|
|
290
340
|
fi
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Final-worktree diff facts shared by proof generation and verification."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_EMPTY = {"count": 0, "insertions": 0, "deletions": 0, "files": []}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _git(repo_dir, args, allowed=(0,)):
|
|
13
|
+
try:
|
|
14
|
+
result = subprocess.run(
|
|
15
|
+
["git", "-C", repo_dir] + args,
|
|
16
|
+
capture_output=True,
|
|
17
|
+
text=True,
|
|
18
|
+
encoding="utf-8",
|
|
19
|
+
errors="surrogateescape",
|
|
20
|
+
timeout=30,
|
|
21
|
+
)
|
|
22
|
+
except Exception:
|
|
23
|
+
return None
|
|
24
|
+
return result.stdout if result.returncode in allowed else None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _excluded(path):
|
|
28
|
+
return path == ".loki" or path.startswith(".loki/")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_numstat(raw):
|
|
32
|
+
files = []
|
|
33
|
+
for record in (raw or "").split("\0"):
|
|
34
|
+
if not record:
|
|
35
|
+
continue
|
|
36
|
+
parts = record.split("\t", 2)
|
|
37
|
+
if len(parts) != 3 or _excluded(parts[2]):
|
|
38
|
+
continue
|
|
39
|
+
ins_s, del_s, path = parts
|
|
40
|
+
files.append({
|
|
41
|
+
"path": path,
|
|
42
|
+
"insertions": 0 if ins_s == "-" else int(ins_s),
|
|
43
|
+
"deletions": 0 if del_s == "-" else int(del_s),
|
|
44
|
+
"status": "binary" if ins_s == "-" else "modified",
|
|
45
|
+
})
|
|
46
|
+
return files
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _split_patch(raw):
|
|
50
|
+
chunks = []
|
|
51
|
+
current = []
|
|
52
|
+
for line in (raw or "").splitlines(keepends=True):
|
|
53
|
+
if line.startswith("diff --git ") and current:
|
|
54
|
+
chunks.append("".join(current))
|
|
55
|
+
current = [line]
|
|
56
|
+
else:
|
|
57
|
+
current.append(line)
|
|
58
|
+
if current:
|
|
59
|
+
chunks.append("".join(current))
|
|
60
|
+
return chunks
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def collect_workspace_diff(repo_dir, base, include_diffs=False):
|
|
64
|
+
"""Describe final tracked and untracked bytes relative to ``base``.
|
|
65
|
+
|
|
66
|
+
``git diff <base>`` compares the base tree to the final working tree, so a
|
|
67
|
+
single result covers committed, staged, unstaged, and deleted tracked
|
|
68
|
+
files without double counting. Git omits untracked files, which are added
|
|
69
|
+
explicitly. Harness-owned ``.loki`` state is never product work.
|
|
70
|
+
"""
|
|
71
|
+
if _git(repo_dir, ["rev-parse", "--is-inside-work-tree"]) is None:
|
|
72
|
+
return dict(_EMPTY), None
|
|
73
|
+
|
|
74
|
+
comparison = base or "HEAD~1"
|
|
75
|
+
raw = _git(repo_dir, ["diff", "--no-renames", "--numstat", "-z", comparison, "--"])
|
|
76
|
+
if raw is None:
|
|
77
|
+
comparison = "HEAD"
|
|
78
|
+
raw = _git(repo_dir, ["diff", "--no-renames", "--numstat", "-z", comparison, "--"])
|
|
79
|
+
if raw is None:
|
|
80
|
+
return dict(_EMPTY), None
|
|
81
|
+
|
|
82
|
+
files = _parse_numstat(raw)
|
|
83
|
+
diffs = [] if include_diffs else None
|
|
84
|
+
if diffs is not None:
|
|
85
|
+
patch = _git(repo_dir, ["diff", "--no-renames", comparison, "--"])
|
|
86
|
+
for chunk in _split_patch(patch):
|
|
87
|
+
first = chunk.splitlines()[0] if chunk else ""
|
|
88
|
+
path = first.split(" b/", 1)[1] if " b/" in first else ""
|
|
89
|
+
if not _excluded(path):
|
|
90
|
+
diffs.append({"path": path, "patch": chunk})
|
|
91
|
+
|
|
92
|
+
untracked = _git(repo_dir, ["ls-files", "--others", "--exclude-standard", "-z"])
|
|
93
|
+
for path in sorted(p for p in (untracked or "").split("\0") if p and not _excluded(p)):
|
|
94
|
+
stat = _git(
|
|
95
|
+
repo_dir,
|
|
96
|
+
["diff", "--no-index", "--no-renames", "--numstat", "-z", "--", "/dev/null", path],
|
|
97
|
+
allowed=(0, 1),
|
|
98
|
+
)
|
|
99
|
+
parsed = _parse_numstat(stat)
|
|
100
|
+
entry = parsed[0] if parsed else {
|
|
101
|
+
"path": path, "insertions": 0, "deletions": 0, "status": "untracked"
|
|
102
|
+
}
|
|
103
|
+
entry["path"] = path
|
|
104
|
+
entry["status"] = "untracked_binary" if entry["status"] == "binary" else "untracked"
|
|
105
|
+
files.append(entry)
|
|
106
|
+
if diffs is not None:
|
|
107
|
+
patch = _git(
|
|
108
|
+
repo_dir,
|
|
109
|
+
["diff", "--no-index", "--no-renames", "--", "/dev/null", path],
|
|
110
|
+
allowed=(0, 1),
|
|
111
|
+
)
|
|
112
|
+
if patch:
|
|
113
|
+
diffs.append({"path": path, "patch": patch})
|
|
114
|
+
|
|
115
|
+
files.sort(key=lambda item: item["path"])
|
|
116
|
+
return {
|
|
117
|
+
"count": len(files),
|
|
118
|
+
"insertions": sum(item["insertions"] for item in files),
|
|
119
|
+
"deletions": sum(item["deletions"] for item in files),
|
|
120
|
+
"files": files,
|
|
121
|
+
}, diffs
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = ["collect_workspace_diff"]
|