loki-mode 7.129.5 → 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.
Files changed (74) hide show
  1. package/README.md +81 -5
  2. package/SKILL.md +66 -5
  3. package/VERSION +1 -1
  4. package/autonomy/app-runner.sh +37 -0
  5. package/autonomy/completion-council.sh +862 -28
  6. package/autonomy/council-v2.sh +39 -0
  7. package/autonomy/crash.sh +3 -2
  8. package/autonomy/grill.sh +42 -0
  9. package/autonomy/hooks/validate-bash.sh +301 -4
  10. package/autonomy/lib/cockpit-render.sh +8 -2
  11. package/autonomy/lib/cr-rematerialize.py +101 -4
  12. package/autonomy/lib/deadline.py +957 -0
  13. package/autonomy/lib/dependency-setup.sh +205 -0
  14. package/autonomy/lib/done-recognition.sh +45 -0
  15. package/autonomy/lib/efficiency_cost.py +13 -6
  16. package/autonomy/lib/no_mock_scan.py +793 -0
  17. package/autonomy/lib/prd-enrich.sh +42 -0
  18. package/autonomy/lib/proof-analytics-props.py +69 -0
  19. package/autonomy/lib/proof-generator.py +282 -95
  20. package/autonomy/lib/proof-template.html +15 -12
  21. package/autonomy/lib/proof-verify.py +151 -102
  22. package/autonomy/lib/requirements_contract.py +848 -0
  23. package/autonomy/lib/sdk-mode.sh +103 -0
  24. package/autonomy/lib/secret-scan.sh +139 -0
  25. package/autonomy/lib/spec-expand.sh +208 -0
  26. package/autonomy/lib/tree_digest.py +266 -0
  27. package/autonomy/lib/voter-agents.sh +58 -8
  28. package/autonomy/lib/workspace_diff.py +124 -0
  29. package/autonomy/loki +189 -17
  30. package/autonomy/playwright-verify.sh +501 -0
  31. package/autonomy/prd-checklist.sh +17 -8
  32. package/autonomy/provider-offer.sh +111 -0
  33. package/autonomy/run.sh +4417 -676
  34. package/autonomy/sandbox.sh +42 -0
  35. package/autonomy/spec-interrogation.sh +148 -5
  36. package/autonomy/spec.sh +118 -1
  37. package/autonomy/telemetry.sh +133 -7
  38. package/autonomy/verify.sh +107 -0
  39. package/bin/loki +110 -3
  40. package/completions/_loki +10 -0
  41. package/completions/loki.bash +1 -1
  42. package/dashboard/__init__.py +1 -1
  43. package/dashboard/audit.py +96 -7
  44. package/dashboard/build_supervisor.py +1619 -0
  45. package/dashboard/control.py +8 -0
  46. package/dashboard/prompt_optimizer.py +7 -0
  47. package/dashboard/server.py +577 -22
  48. package/dashboard/static/trust.html +1 -1
  49. package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
  50. package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
  51. package/docs/INSTALLATION.md +19 -2
  52. package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
  53. package/docs/PLANS-INDEX.md +33 -0
  54. package/docs/PRIVACY.md +29 -1
  55. package/docs/SIGNED-RECEIPTS.md +102 -0
  56. package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
  57. package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
  58. package/docs/V8-AGENT-SDK-PLAN.md +692 -0
  59. package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
  60. package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
  61. package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
  62. package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
  63. package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
  64. package/docs/alternative-installations.md +4 -4
  65. package/loki-ts/dist/loki.js +674 -285
  66. package/loki-ts/package.json +2 -0
  67. package/mcp/__init__.py +1 -1
  68. package/package.json +7 -6
  69. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  70. package/providers/claude.sh +75 -0
  71. package/providers/codex.sh +85 -13
  72. package/references/sdk-mode.md +106 -0
  73. package/skills/model-selection.md +1 -1
  74. 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
- # Capture stderr to a per-iteration log so hung / failing claude
256
- # invocations are diagnosable instead of silently swallowed. Per Opus #2
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"]