loki-mode 11.17.1 → 11.17.6

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 (109) hide show
  1. package/SKILL.md +3 -3
  2. package/VERSION +1 -1
  3. package/autonomy/grill.sh +24 -2
  4. package/autonomy/hooks/precompact-flush.sh +51 -0
  5. package/autonomy/hooks/validate-bash.sh +895 -5
  6. package/autonomy/lib/chat.sh +8 -3
  7. package/autonomy/lib/claude-flags.sh +143 -6
  8. package/autonomy/lib/claude_permissions.py +24 -4
  9. package/autonomy/lib/decision_record.py +92 -3
  10. package/autonomy/lib/efficiency_cost.py +102 -0
  11. package/autonomy/lib/modernize/py_capture.py +62 -3
  12. package/autonomy/lib/prd-enrich.sh +28 -2
  13. package/autonomy/lib/voter-agents.sh +61 -6
  14. package/autonomy/lib/wiki-generator.py +58 -10
  15. package/autonomy/lib/wiki_llm.py +41 -2
  16. package/autonomy/loki +391 -28
  17. package/autonomy/quickstart.sh +1 -1
  18. package/autonomy/run.sh +201 -10
  19. package/autonomy/trigger-server.py +70 -2
  20. package/completions/_loki +3 -0
  21. package/completions/loki.bash +2 -1
  22. package/dashboard/__init__.py +1 -1
  23. package/dashboard/audit.py +204 -17
  24. package/docs/CLI-REFERENCE.md +4 -1
  25. package/docs/EVALUATING.md +2 -2
  26. package/docs/INSTALLATION.md +1 -1
  27. package/docs/environment-variables.md +1 -0
  28. package/docs/trackers.md +6 -0
  29. package/docs/v10/BOARD.md +7 -4
  30. package/docs/v10/CLI-MODERN.md +8 -5
  31. package/docs/v10/DECISIONS.md +31 -1
  32. package/docs/v10/ENGINE.md +1 -0
  33. package/docs/v10/FAILURE-CLASSES.md +60 -1
  34. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  35. package/docs/v10/GUIDE-MODERNIZE.md +9 -4
  36. package/docs/v10/PLAN-V12.md +896 -0
  37. package/docs/v10/PROGRESS.md +10 -0
  38. package/docs/v11/COST-HALF.md +3 -1
  39. package/docs/visual-evidence.md +2 -0
  40. package/loki-ts/dist/loki.js +1104 -1057
  41. package/mcp/__init__.py +1 -1
  42. package/memory/retrieval.py +15 -3
  43. package/memory/schemas.py +43 -1
  44. package/memory/storage.py +11 -0
  45. package/package.json +2 -2
  46. package/packages/control-plane/dist/server.js +989 -693
  47. package/packages/control-plane/src/server/legacy/routes-control.ts +5 -1
  48. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  49. package/providers/claude.sh +1 -0
  50. package/providers/loader.sh +26 -0
  51. package/providers/model_routes.sh +31 -0
  52. package/src/policies/claude_permissions.js +20 -2
  53. package/web-app/dist/assets/{AdminPage-OVzN6eLm.js → AdminPage-CCedYmn3.js} +1 -1
  54. package/web-app/dist/assets/{Avatar-hCCrxrCZ.js → Avatar-DMrDsuGe.js} +1 -1
  55. package/web-app/dist/assets/{Badge-CDcpkhbJ.js → Badge-K4Ui2tJz.js} +1 -1
  56. package/web-app/dist/assets/{Button-8mwWlhEu.js → Button-Bhjts5QZ.js} +1 -1
  57. package/web-app/dist/assets/{CockpitPage-D8cf3u22.js → CockpitPage-Be7sEVqh.js} +1 -1
  58. package/web-app/dist/assets/{ComparePage-CIgkbDRY.js → ComparePage-D4ty3jiF.js} +1 -1
  59. package/web-app/dist/assets/{ErrorBoundary-99kJH9g2.js → ErrorBoundary-Cmt2x_mL.js} +1 -1
  60. package/web-app/dist/assets/{EvidenceReceiptPanel-C9fj32eS.js → EvidenceReceiptPanel-D2IXLnp9.js} +1 -1
  61. package/web-app/dist/assets/{GitHubIssuesPanel-De43YQ_C.js → GitHubIssuesPanel-CyXitr6h.js} +1 -1
  62. package/web-app/dist/assets/{GitHubPRsPanel-7g7ZTnkx.js → GitHubPRsPanel-RRM3H-xd.js} +1 -1
  63. package/web-app/dist/assets/{HomePage-CcNA24ny.js → HomePage-CMtrrgfs.js} +2 -2
  64. package/web-app/dist/assets/{LoginPage-BYjlb-IK.js → LoginPage-BrzNhnMJ.js} +1 -1
  65. package/web-app/dist/assets/{MagicPage-C0Dwt9Na.js → MagicPage-D7VynOKM.js} +1 -1
  66. package/web-app/dist/assets/{MetricsPage-jDGmdcgg.js → MetricsPage-DJ-gDbfP.js} +1 -1
  67. package/web-app/dist/assets/{NotFoundPage-C_ZO8tLE.js → NotFoundPage-BJh0XVTj.js} +1 -1
  68. package/web-app/dist/assets/{ProjectPage-B5wnurfQ.js → ProjectPage-CAodVUpE.js} +3 -3
  69. package/web-app/dist/assets/{ProjectsPage--EyKt5-D.js → ProjectsPage-m3siS0Rf.js} +1 -1
  70. package/web-app/dist/assets/{SettingsPage-cB0lnfti.js → SettingsPage-CFGgjOvy.js} +1 -1
  71. package/web-app/dist/assets/{ShowcasePage-Dl16FDkK.js → ShowcasePage-Dd9tpSKJ.js} +1 -1
  72. package/web-app/dist/assets/{SystemSettingsPage-BmAXd4V2.js → SystemSettingsPage-DXd0PtTI.js} +1 -1
  73. package/web-app/dist/assets/{TeamsPage-BFkWVOZe.js → TeamsPage-C5KaLIGW.js} +1 -1
  74. package/web-app/dist/assets/{TemplatesPage-Bf2kmEUM.js → TemplatesPage-CDbzE0ZZ.js} +1 -1
  75. package/web-app/dist/assets/{TerminalOutput-BItv9Wa6.js → TerminalOutput-CKG79r_-.js} +1 -1
  76. package/web-app/dist/assets/{activity-Df2Awg6I.js → activity-BaQQRhDp.js} +1 -1
  77. package/web-app/dist/assets/{bell-D-1ztTsJ.js → bell-OeFqvU4e.js} +1 -1
  78. package/web-app/dist/assets/{bot-A4qgZsKi.js → bot-CSxaA0KU.js} +1 -1
  79. package/web-app/dist/assets/{check-BPfFpF0c.js → check-DJml2Xdn.js} +1 -1
  80. package/web-app/dist/assets/{chevron-left-CfSJvmDi.js → chevron-left-DGnt-DtK.js} +1 -1
  81. package/web-app/dist/assets/{circle-alert-wx2d1dzb.js → circle-alert-BV5rVL7K.js} +1 -1
  82. package/web-app/dist/assets/{clock-pQMai6l-.js → clock-CZ7SvULz.js} +1 -1
  83. package/web-app/dist/assets/{cloud-DWL470r6.js → cloud-lkIzrZps.js} +1 -1
  84. package/web-app/dist/assets/{code-xml-BaEQpQLZ.js → code-xml-DzA2wL8F.js} +1 -1
  85. package/web-app/dist/assets/{database-h-N7WkQN.js → database-CLFG5upC.js} +1 -1
  86. package/web-app/dist/assets/{dollar-sign-B5oYiD27.js → dollar-sign-DgWePGsv.js} +1 -1
  87. package/web-app/dist/assets/{file-code-corner-fbTtTy0o.js → file-code-corner-DGQX2bmv.js} +1 -1
  88. package/web-app/dist/assets/{file-plus-PaF6HMo8.js → file-plus-LkaXeYd6.js} +1 -1
  89. package/web-app/dist/assets/{globe-Ct-GlWID.js → globe-rSsEzPYv.js} +1 -1
  90. package/web-app/dist/assets/{hammer-C1oZFX51.js → hammer-CPogONt9.js} +1 -1
  91. package/web-app/dist/assets/{index-C2egrRhz.js → index-DpBLuMuz.js} +3 -3
  92. package/web-app/dist/assets/{layers-Dt5ZK4v2.js → layers-CwFaqidn.js} +1 -1
  93. package/web-app/dist/assets/{loader-circle-DrpMziic.js → loader-circle-C43izEm5.js} +1 -1
  94. package/web-app/dist/assets/{lock-DOez8MmK.js → lock-Bc_Uqxpp.js} +1 -1
  95. package/web-app/dist/assets/{package-ClGIkerr.js → package-BM25lcR-.js} +1 -1
  96. package/web-app/dist/assets/{plus-C004i9ua.js → plus-D0xoiY6N.js} +1 -1
  97. package/web-app/dist/assets/{refresh-cw-Ddfk-7Sy.js → refresh-cw-COI1R5Oo.js} +1 -1
  98. package/web-app/dist/assets/{rotate-ccw-Bu1KgDjS.js → rotate-ccw-BoNZx4xe.js} +1 -1
  99. package/web-app/dist/assets/{scroll-text-3q1lW5pm.js → scroll-text-Cakp8XHd.js} +1 -1
  100. package/web-app/dist/assets/{server-pd6x4HW1.js → server-CjfIlPmE.js} +1 -1
  101. package/web-app/dist/assets/{shield-alert-B9jGJepT.js → shield-alert-BilhziJF.js} +1 -1
  102. package/web-app/dist/assets/{thumbs-up-Cdcc9nQf.js → thumbs-up-CglMrtrt.js} +1 -1
  103. package/web-app/dist/assets/{trash-2-foSojXHK.js → trash-2-B6zVvnoG.js} +1 -1
  104. package/web-app/dist/assets/{trending-down-CTTiqlJ1.js → trending-down-BVhwD9bt.js} +1 -1
  105. package/web-app/dist/assets/{trending-up-B_MgmRAx.js → trending-up-CVJ1ox6c.js} +1 -1
  106. package/web-app/dist/assets/{upload-g6BfALzG.js → upload-DmuR09WL.js} +1 -1
  107. package/web-app/dist/assets/{usePolling-DrHwvzuh.js → usePolling-BTALfO91.js} +1 -1
  108. package/web-app/dist/assets/{user-COp75m7l.js → user-CRdHMZNH.js} +1 -1
  109. package/web-app/dist/index.html +1 -1
@@ -113,7 +113,7 @@ _chat_sleep_ms() {
113
113
  # that was not live before). A start that exits first reports its real error and the log path.
114
114
  # Returns non-zero when the start is not proven.
115
115
  _chat_run_launch() {
116
- local loki="$1" input="$2" root="${LOKI_DIR:-.loki}" log pid rc=0 start_ms deadline_ms now_ms rem_ms iter=0 max_iter before_dir before_state
116
+ local loki="$1" input="$2" root="${LOKI_DIR:-.loki}" log pid rc=0 start_ms deadline_ms now_ms rem_ms iter=0 clock_failed=0 max_iter before_dir before_state
117
117
  log="$root/chat-run.log"
118
118
  mkdir -p "$root"
119
119
  _chat_clock_pick
@@ -151,14 +151,19 @@ _chat_run_launch() {
151
151
  return "$rc"
152
152
  fi
153
153
  # A failed or non-numeric clock read (perl fork EAGAIN) counts as the deadline: never mix clock bases.
154
- now_ms="$(_chat_now_ms)" || break
155
- case "$now_ms" in ''|*[!0-9]*) break ;; esac
154
+ # No $SECONDS fallback here: switching sources mid-launch would mix bases with start_ms.
155
+ now_ms="$(_chat_now_ms)" || { clock_failed=1; break; }
156
+ case "$now_ms" in ''|*[!0-9]*) clock_failed=1; break ;; esac
156
157
  rem_ms=$(( deadline_ms - now_ms ))
157
158
  [ "$rem_ms" -gt 0 ] || break
158
159
  [ "$iter" -lt "$max_iter" ] || break
159
160
  [ "$rem_ms" -le 200 ] || rem_ms=200
160
161
  _chat_sleep_ms "$rem_ms"
161
162
  done
163
+ if [ "$clock_failed" = 1 ]; then
164
+ echo "Run start unproven (clock read failed while waiting; launcher pid $pid still running). Log: $log" >&2
165
+ return 1
166
+ fi
162
167
  echo "Run start not confirmed after ${wait_s}s (no live run state; launcher pid $pid still running). Log: $log" >&2
163
168
  return 1
164
169
  }
@@ -212,6 +212,93 @@ _loki_apikey_helper_configured() {
212
212
  return 1
213
213
  }
214
214
 
215
+ # ---------- S2: lean child-session flags + cost ledger ----------
216
+ # loki_lean_child_flags <role> prints the lean flag set for a NON-MAIN child
217
+ # `claude -p` session, ONE ARGUMENT PER LINE (never an empty line).
218
+ # Callers read it with a line loop.
219
+ # - API-key/apiKeyHelper host: "--bare" (gated by loki_subcall_bare_enabled,
220
+ # which keeps OAuth-only hosts off --bare; it would fail auth there).
221
+ # - Otherwise: --setting-sources project, --strict-mcp-config, an empty
222
+ # --mcp-config, --disable-slash-commands.
223
+ # - Text-only roles additionally get the single argument "--tools=" (no tools). --tools is
224
+ # variadic: "--tools" plus an empty argument swallows a trailing prompt.
225
+ # Every flag is probed with loki_claude_flag_supported; an unsupported flag and
226
+ # its value are dropped. LOKI_LEAN_CHILDREN=0 prints nothing. Main-session
227
+ # flags are not touched by this helper.
228
+ loki_lean_child_flags() {
229
+ local role="${1:-}"
230
+ [ "${LOKI_LEAN_CHILDREN:-1}" = "0" ] && return 0
231
+ if loki_subcall_bare_enabled; then
232
+ printf '%s\n' "--bare"
233
+ else
234
+ if loki_claude_flag_supported "--setting-sources"; then
235
+ printf '%s\n' "--setting-sources" "project"
236
+ fi
237
+ if loki_claude_flag_supported "--strict-mcp-config"; then
238
+ printf '%s\n' "--strict-mcp-config"
239
+ fi
240
+ if loki_claude_flag_supported "--mcp-config"; then
241
+ printf '%s\n' "--mcp-config" '{"mcpServers":{}}'
242
+ fi
243
+ if loki_claude_flag_supported "--disable-slash-commands"; then
244
+ printf '%s\n' "--disable-slash-commands"
245
+ fi
246
+ fi
247
+ case "$role" in
248
+ grill|council|vote|review|reviewer|enrich|wiki|docs|docgen|summary)
249
+ if loki_claude_flag_supported "--tools"; then
250
+ printf '%s\n' "--tools="
251
+ fi
252
+ ;;
253
+ esac
254
+ return 0
255
+ }
256
+
257
+ # loki_child_ledger_append <phase> <claude --output-format json> appends one
258
+ # line to .loki/metrics/child-sessions.jsonl in the format read by
259
+ # autonomy/lib/efficiency_cost.py read_child_ledger: phase, model,
260
+ # input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, usd,
261
+ # source. A missing usd is omitted, never written as 0. Returns 1 (writes
262
+ # nothing) when the json is not a parseable result object.
263
+ loki_child_ledger_append() {
264
+ local phase="${1:-}" payload="${2:-}"
265
+ [ -n "$phase" ] && [ -n "$payload" ] || return 1
266
+ local dir="${LOKI_DIR:-.loki}/metrics" line
267
+ line=$(printf '%s' "$payload" | python3 -I -c '
268
+ import json, sys
269
+ phase = sys.argv[1]
270
+ try:
271
+ d = json.loads(sys.stdin.read())
272
+ except Exception:
273
+ sys.exit(1)
274
+ if isinstance(d, list):
275
+ d = next((x for x in reversed(d) if isinstance(x, dict) and x.get("type") == "result"), None)
276
+ if not isinstance(d, dict):
277
+ sys.exit(1)
278
+ u = d.get("usage") if isinstance(d.get("usage"), dict) else {}
279
+ def n(v):
280
+ return v if isinstance(v, int) and not isinstance(v, bool) else 0
281
+ mu = d.get("modelUsage")
282
+ model = next(iter(mu), "") if isinstance(mu, dict) and mu else (d.get("model") or "")
283
+ rec = {
284
+ "phase": phase,
285
+ "model": model if isinstance(model, str) else "",
286
+ "input_tokens": n(u.get("input_tokens")),
287
+ "output_tokens": n(u.get("output_tokens")),
288
+ "cache_creation_tokens": n(u.get("cache_creation_input_tokens")),
289
+ "cache_read_tokens": n(u.get("cache_read_input_tokens")),
290
+ }
291
+ usd = d.get("total_cost_usd", d.get("cost_usd"))
292
+ if isinstance(usd, (int, float)) and not isinstance(usd, bool):
293
+ rec["usd"] = usd
294
+ rec["source"] = "claude-json"
295
+ print(json.dumps(rec, separators=(",", ":")))
296
+ ' "$phase" 2>/dev/null) || return 1
297
+ [ -n "$line" ] || return 1
298
+ mkdir -p "$dir" 2>/dev/null || return 1
299
+ printf '%s\n' "$line" >> "$dir/child-sessions.jsonl" || return 1
300
+ }
301
+
215
302
  # EMBED 3 -- --disallowedTools (reviewer / adversarial subcalls). Motivation: a
216
303
  # parallel agent once ran `git reset --hard` and wiped uncommitted work, so a
217
304
  # reviewer/voter subcall should not casually mutate the tree. Per `claude --help`
@@ -973,18 +1060,68 @@ loki_export_subagent_capacity() {
973
1060
  # without each one having to remember to call this.
974
1061
  loki_export_subagent_capacity
975
1062
 
976
- # P12-26: LOKI_PERMISSION_POLICY=compiled swaps --dangerously-skip-permissions for
977
- # --permission-mode / --allowedTools / --disallowedTools compiled from the task policy
978
- # by src/policies/claude_permissions.js (shared with the TS permissionArgv). Unset =
979
- # unchanged. Result lands in _LOKI_PERM_ARGV. A compile failure fails closed to plan.
1063
+ # P12-26 / C-C2: the compiled permission policy swaps --dangerously-skip-permissions for
1064
+ # --permission-mode / --allowedTools / --disallowedTools compiled from the task policy by
1065
+ # src/policies/claude_permissions.js (shared with the TS permissionArgv). Resolution:
1066
+ # LOKI_PERMISSION_POLICY=legacy -> legacy (old flag, byte-identical)
1067
+ # LOKI_PERMISSION_POLICY=compiled -> compiled (fail-closed acceptEdits with no policy)
1068
+ # unset -> compiled only when an operator claude_permissions
1069
+ # policy exists, else legacy (headless acceptEdits
1070
+ # with no allowedTools denies npm test / git commit)
1071
+ # any other value -> compiled, with a one-time warning per process
1072
+ # Result lands in _LOKI_PERM_ARGV. A compile failure fails closed to plan.
1073
+ _LOKI_PERM_UNKNOWN_WARNED=0
1074
+ _loki_permission_policy_mode() {
1075
+ case "${LOKI_PERMISSION_POLICY:-}" in
1076
+ legacy) echo legacy ;;
1077
+ compiled) echo compiled ;;
1078
+ "")
1079
+ local _helper
1080
+ _helper="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/src/policies/claude_permissions.js"
1081
+ if command -v node >/dev/null 2>&1 && [ -f "$_helper" ] \
1082
+ && [ "$(node "$_helper" --has-operator-policy 2>/dev/null)" = "1" ]; then
1083
+ echo compiled
1084
+ else
1085
+ echo legacy
1086
+ fi
1087
+ ;;
1088
+ *) echo compiled ;;
1089
+ esac
1090
+ }
1091
+
1092
+ # Unknown-value warning, once per process. Runs in the caller shell (never inside $(...)),
1093
+ # so the guard variable persists.
1094
+ _loki_permission_warn_unknown_once() {
1095
+ case "${LOKI_PERMISSION_POLICY:-}" in
1096
+ ""|legacy|compiled) return 0 ;;
1097
+ esac
1098
+ [ "${_LOKI_PERM_UNKNOWN_WARNED:-0}" = "1" ] && return 0
1099
+ _LOKI_PERM_UNKNOWN_WARNED=1
1100
+ echo "[loki] WARNING: LOKI_PERMISSION_POLICY=${LOKI_PERMISSION_POLICY}: unknown value; using compiled" >&2
1101
+ }
1102
+
1103
+ # The one start-banner line naming the active mode.
1104
+ loki_permission_policy_banner() {
1105
+ if [ "$(_loki_permission_policy_mode 2>/dev/null)" = "legacy" ]; then
1106
+ if [ -z "${LOKI_PERMISSION_POLICY:-}" ]; then
1107
+ echo "Permission policy: legacy (no operator policy found; add one to use compiled)"
1108
+ else
1109
+ echo "Permission policy: legacy (unset LOKI_PERMISSION_POLICY to use compiled)"
1110
+ fi
1111
+ else
1112
+ echo "Permission policy: compiled (set LOKI_PERMISSION_POLICY=legacy to revert)"
1113
+ fi
1114
+ }
1115
+
980
1116
  _loki_claude_permission_argv() {
981
1117
  _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
982
- [ "${LOKI_PERMISSION_POLICY:-}" = "compiled" ] || return 0
1118
+ _loki_permission_warn_unknown_once
1119
+ [ "$(_loki_permission_policy_mode)" = "legacy" ] && return 0
983
1120
  local _helper _out _a
984
1121
  _helper="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/src/policies/claude_permissions.js"
985
1122
  _LOKI_PERM_ARGV=(--permission-mode plan)
986
1123
  if ! command -v node >/dev/null 2>&1 || [ ! -f "$_helper" ]; then
987
- echo "[loki] WARNING: LOKI_PERMISSION_POLICY=compiled: compiler unavailable; using most restrictive mode (plan)" >&2
1124
+ echo "[loki] WARNING: compiled permission policy: compiler unavailable; using most restrictive mode (plan)" >&2
988
1125
  return 0
989
1126
  fi
990
1127
  _out="$(node "$_helper" "${LOKI_PROJECT_DIR:-$PWD}")" || return 0
@@ -2,8 +2,9 @@
2
2
 
3
3
  New file: the python launch sites (dashboard, magic) need the same argv as the
4
4
  bash and TS routes; this is a thin caller of src/policies/claude_permissions.js
5
- (the single compiler), not a second implementation. Unset LOKI_PERMISSION_POLICY
6
- returns the legacy flag byte-identically.
5
+ (the single compiler), not a second implementation. LOKI_PERMISSION_POLICY=legacy returns
6
+ the legacy flag byte-identically; unset resolves to compiled only when an operator
7
+ claude_permissions policy exists (asked of the same js helper), else legacy (C-C2).
7
8
  """
8
9
  from __future__ import annotations
9
10
 
@@ -15,16 +16,35 @@ from pathlib import Path
15
16
 
16
17
  _LEGACY = ["--dangerously-skip-permissions"]
17
18
  _FAIL_CLOSED = ["--permission-mode", "plan"]
19
+ _unknown_warned = False
20
+
21
+
22
+ def _has_operator_policy(node: str | None, helper: Path, env: dict) -> bool:
23
+ if node is None or not helper.is_file():
24
+ return False
25
+ try:
26
+ done = subprocess.run([node, str(helper), "--has-operator-policy"], capture_output=True,
27
+ text=True, timeout=30, env=dict(env), check=False)
28
+ except (OSError, subprocess.SubprocessError):
29
+ return False
30
+ return done.stdout.strip() == "1"
18
31
 
19
32
 
20
33
  def claude_permission_argv(project_dir: str | None = None, env: dict | None = None) -> list:
21
34
  env = os.environ if env is None else env
22
- if env.get("LOKI_PERMISSION_POLICY", "") != "compiled":
35
+ mode = env.get("LOKI_PERMISSION_POLICY", "")
36
+ if mode == "legacy":
23
37
  return list(_LEGACY)
38
+ global _unknown_warned
24
39
  helper = Path(__file__).resolve().parents[2] / "src" / "policies" / "claude_permissions.js"
25
40
  node = shutil.which("node")
41
+ if mode == "" and not _has_operator_policy(node, helper, env):
42
+ return list(_LEGACY)
43
+ if mode not in ("", "compiled") and not _unknown_warned:
44
+ _unknown_warned = True
45
+ print(f"[loki] WARNING: LOKI_PERMISSION_POLICY={mode}: unknown value; using compiled", file=sys.stderr)
26
46
  if node is None or not helper.is_file():
27
- print("[loki] WARNING: LOKI_PERMISSION_POLICY=compiled: compiler unavailable; using most restrictive mode (plan)", file=sys.stderr)
47
+ print("[loki] WARNING: compiled permission policy: compiler unavailable; using most restrictive mode (plan)", file=sys.stderr)
28
48
  return list(_FAIL_CLOSED)
29
49
  target = project_dir or env.get("LOKI_PROJECT_DIR") or os.getcwd()
30
50
  try:
@@ -168,22 +168,111 @@ def summarize(loki_dir):
168
168
  }
169
169
 
170
170
 
171
+ # DESIGN RECORDS. A second record type next to the dispatch record: why a design
172
+ # approach was chosen, not which model ran. Kept in its own file so
173
+ # summarize() and verdict.py, which read decisions.jsonl as dispatch records,
174
+ # never see a record without model_id. Same rules: fixed allowlist, append-only.
175
+ DESIGN_FIELDS = (
176
+ "schema_version",
177
+ "recorded_at",
178
+ "rationale", # required: why this approach
179
+ "alternatives", # list of approaches considered and not chosen
180
+ "source", # what made the call, e.g. plan
181
+ "scope", # what the decision covers
182
+ "decided_at",
183
+ )
184
+
185
+
186
+ def _design_path(loki_dir):
187
+ return os.path.join(loki_dir, "decisions", "design.jsonl")
188
+
189
+
190
+ def record_design(loki_dir, fields):
191
+ """Append one design decision record, or return a named refusal."""
192
+ clean = {}
193
+ dropped = []
194
+ for k, v in (fields or {}).items():
195
+ if k in DESIGN_FIELDS:
196
+ clean[k] = v
197
+ else:
198
+ dropped.append(k)
199
+
200
+ rationale = clean.get("rationale")
201
+ if not isinstance(rationale, str) or not rationale.strip():
202
+ return {"status": "UNKNOWN", "reason": "no_rationale",
203
+ "detail": "a design record without a rationale records nothing about why"}
204
+ alts = clean.get("alternatives", [])
205
+ if isinstance(alts, str):
206
+ alts = [a.strip() for a in alts.split("|") if a.strip()]
207
+ if not isinstance(alts, list) or not all(isinstance(a, str) for a in alts):
208
+ return {"status": "UNKNOWN", "reason": "bad_alternatives",
209
+ "detail": "alternatives must be a list of strings"}
210
+ for k in ("source", "scope", "decided_at"):
211
+ if k in clean and not isinstance(clean[k], str):
212
+ return {"status": "UNKNOWN", "reason": "bad_" + k,
213
+ "detail": k + " must be a string"}
214
+
215
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
216
+ clean["rationale"] = rationale.strip()
217
+ clean["alternatives"] = alts
218
+ clean.setdefault("decided_at", now)
219
+ clean["schema_version"] = SCHEMA_VERSION
220
+ clean["recorded_at"] = now
221
+
222
+ path = _design_path(loki_dir)
223
+ try:
224
+ os.makedirs(os.path.dirname(path), exist_ok=True)
225
+ with open(path, "a", encoding="utf-8") as fh:
226
+ fh.write(json.dumps(clean, sort_keys=True) + "\n")
227
+ except OSError as exc:
228
+ return {"status": "UNKNOWN", "reason": "unwritable", "detail": str(exc)}
229
+
230
+ out = {"status": "recorded", "path": path, "record": clean}
231
+ if dropped:
232
+ out["dropped_fields"] = sorted(dropped)
233
+ return out
234
+
235
+
236
+ def read_design(loki_dir):
237
+ """Return the parseable design records, oldest first."""
238
+ path = _design_path(loki_dir)
239
+ recs = []
240
+ try:
241
+ with open(path, "r", encoding="utf-8") as fh:
242
+ for line in fh:
243
+ try:
244
+ recs.append(json.loads(line))
245
+ except ValueError:
246
+ continue
247
+ except OSError:
248
+ pass
249
+ return recs
250
+
251
+
171
252
  def main(argv):
172
253
  if not argv:
173
- print("usage: decision_record.py record|summary [--field=value ...]",
254
+ print("usage: decision_record.py record|design|design-file|summary [--field=value ...]",
174
255
  file=sys.stderr)
175
256
  return 2
176
257
  action = argv[0]
177
258
  cwd = os.environ.get("LOKI_DECISION_CWD") or os.getcwd()
178
259
  loki_dir = os.environ.get("LOKI_DIR") or os.path.join(cwd, ".loki")
179
260
 
180
- if action == "record":
261
+ if action in ("record", "design"):
181
262
  fields = {}
182
263
  for arg in argv[1:]:
183
264
  if arg.startswith("--") and "=" in arg:
184
265
  k, v = arg[2:].split("=", 1)
185
266
  fields[k] = v
186
- res = record(loki_dir, fields)
267
+ res = (record_design if action == "design" else record)(loki_dir, fields)
268
+ elif action == "design-file" and len(argv) == 2:
269
+ try:
270
+ with open(argv[1], "r", encoding="utf-8") as fh:
271
+ data = json.load(fh)
272
+ except (OSError, ValueError):
273
+ data = None
274
+ res = record_design(loki_dir, data) if isinstance(data, dict) else {
275
+ "status": "UNKNOWN", "reason": "unreadable_file"}
187
276
  elif action == "summary":
188
277
  res = summarize(loki_dir)
189
278
  else:
@@ -29,6 +29,7 @@ import os
29
29
  __all__ = [
30
30
  "collect_efficiency",
31
31
  "record_is_measured",
32
+ "read_child_ledger",
32
33
  "load_prices",
33
34
  "price_from_tokens",
34
35
  "DEFAULT_PRICES_PATH",
@@ -223,9 +224,110 @@ def collect_efficiency(loki_dir):
223
224
  "cache_read_tokens", "cache_creation_tokens",
224
225
  ):
225
226
  cost[key] = None
227
+ _merge_child_ledger(loki_dir, cost)
226
228
  return cost, model
227
229
 
228
230
 
231
+ # ---------------------------------------------------------------------------
232
+ # child-session ledger (S1 of the cost program)
233
+ # ---------------------------------------------------------------------------
234
+
235
+ LEDGER_RELPATH = os.path.join("metrics", "child-sessions.jsonl")
236
+ _LEDGER_TOKEN_KEYS = (
237
+ "input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens",
238
+ )
239
+
240
+
241
+ def read_child_ledger(loki_dir):
242
+ """Read .loki/metrics/child-sessions.jsonl, one JSON object per child session.
243
+
244
+ Record format: phase, model, input_tokens, output_tokens,
245
+ cache_creation_tokens, cache_read_tokens, usd, source.
246
+
247
+ Returns (records, invalid_lines) or (None, 0) when the file is absent or
248
+ unreadable. None is the honest "no ledger" signal: callers must not treat
249
+ it as zero child spend. Blank lines are ignored; non-JSON lines and
250
+ non-object lines are skipped and counted.
251
+ """
252
+ path = os.path.join(loki_dir, LEDGER_RELPATH)
253
+ try:
254
+ with open(path, "r", encoding="utf-8") as fh:
255
+ lines = fh.read().splitlines()
256
+ except Exception:
257
+ return None, 0
258
+ records, invalid = [], 0
259
+ for line in lines:
260
+ line = line.strip()
261
+ if not line:
262
+ continue
263
+ try:
264
+ rec = json.loads(line)
265
+ except Exception:
266
+ invalid += 1
267
+ continue
268
+ if isinstance(rec, dict):
269
+ records.append(rec)
270
+ else:
271
+ invalid += 1
272
+ return records, invalid
273
+
274
+
275
+ def _merge_child_ledger(loki_dir, cost):
276
+ """Fold the child-session ledger into the cost dict, in place.
277
+
278
+ The main RARV session's efficiency record is only one of 11-20 child
279
+ `claude -p` sessions per run (grill, reviewers, council, docgen); summing
280
+ it alone undercounts true spend. Always sets cost["ledger"] to "present" or
281
+ "absent" so a missing ledger is visible, never a silent zero. A ledger line
282
+ without a usd value is unpriced (counted, never treated as 0.0), and the
283
+ total then becomes a lower bound via cost_partial.
284
+ """
285
+ records, invalid = read_child_ledger(loki_dir)
286
+ main_usd = cost.get("usd")
287
+ by_phase = {}
288
+ if main_usd is not None:
289
+ by_phase["implement"] = main_usd
290
+ if records is None:
291
+ cost["ledger"] = "absent"
292
+ if by_phase:
293
+ cost["cost_by_phase"] = by_phase
294
+ return
295
+ cost["ledger"] = "present"
296
+ cost["ledger_records"] = len(records)
297
+ cost["ledger_invalid_lines"] = invalid
298
+ if not records:
299
+ if by_phase:
300
+ cost["cost_by_phase"] = by_phase
301
+ return
302
+ ledger_usd, priced, unpriced = 0.0, 0, 0
303
+ main_known = bool(cost.get("available"))
304
+ tokens = {k: cost.get(k) for k in _LEDGER_TOKEN_KEYS}
305
+ if not main_known:
306
+ tokens = {k: 0 for k in _LEDGER_TOKEN_KEYS}
307
+ for rec in records:
308
+ usd = rec.get("usd")
309
+ if isinstance(usd, (int, float)) and not isinstance(usd, bool):
310
+ ledger_usd += float(usd)
311
+ priced += 1
312
+ phase = str(rec.get("phase") or "unknown")
313
+ by_phase[phase] = by_phase.get(phase, 0.0) + float(usd)
314
+ else:
315
+ unpriced += 1
316
+ for k in _LEDGER_TOKEN_KEYS:
317
+ if tokens[k] is not None:
318
+ tokens[k] += _to_int(rec.get(k))
319
+ cost.update(tokens)
320
+ cost["available"] = True
321
+ if priced:
322
+ cost["usd"] = round((main_usd or 0.0) + ledger_usd, 4)
323
+ cost["cost_available"] = True
324
+ partial = bool(cost.get("cost_partial")) or unpriced > 0 or not main_known
325
+ cost["cost_partial"] = partial
326
+ cost["cost_unpriced_records"] = int(cost.get("cost_unpriced_records") or 0) + unpriced
327
+ if by_phase:
328
+ cost["cost_by_phase"] = {k: round(v, 4) for k, v in by_phase.items()}
329
+
330
+
229
331
  # ---------------------------------------------------------------------------
230
332
  # uniform token -> USD pricing (R2 benchmark; not used by the proof generator)
231
333
  # ---------------------------------------------------------------------------
@@ -454,6 +454,25 @@ def _check_boundary(name, value, declared):
454
454
  return None
455
455
 
456
456
 
457
+ def _declared_for_case(declared, per_case, bound_names, call_args, call_kwargs):
458
+ """Per-case boundary declarations (optional "boundaries" key on a --cases line):
459
+ {"args": [kind|null, ...], "kwargs": {name: kind}, "return": kind|null}. This is how a replay declares
460
+ the kinds the ORACLE recorded, so a text argument is not "undeclared". It adds to the --boundaries file,
461
+ never overrides it, and a runtime type that differs from the declared kind is still flagged."""
462
+ if not isinstance(per_case, dict):
463
+ return declared
464
+ merged = dict(declared)
465
+ arg_kinds = per_case.get("args") or []
466
+ kw_kinds = per_case.get("kwargs") or {}
467
+ for i, name in enumerate(bound_names):
468
+ kind = arg_kinds[i] if i < len(call_args) and i < len(arg_kinds) else kw_kinds.get(name)
469
+ if kind in ("text", "bytes") and name not in merged:
470
+ merged[name] = kind
471
+ if per_case.get("return") in ("text", "bytes") and "return" not in merged:
472
+ merged["return"] = per_case["return"]
473
+ return merged
474
+
475
+
457
476
  def _unsupported_not_proven(tagged, path):
458
477
  """Recurse into an already-tagged structure (a return value or an
459
478
  exception arg) and turn every 'unsupported' tag at any nesting depth
@@ -519,6 +538,36 @@ def run_case(func, args, kwargs):
519
538
  # CLI
520
539
  # --------------------------------------------------------------------------
521
540
 
541
+ def _stub_unit_functions(namespace, unit_path):
542
+ """No-op ablation: every plain function defined in the unit file becomes a
543
+ stub returning None. runpy.run_path returns a copy of the module globals, so
544
+ only the entry's own stub matters here; functions called internally still
545
+ see the real module. __wrapped__ keeps the original signature visible to the
546
+ boundary check on Python 3 (inspect.signature follows it). Returns the set of
547
+ names replaced; an entry outside it (imported function, bound-method alias,
548
+ functools.partial, callable instance) was NOT stubbed."""
549
+ import types
550
+ want = os.path.realpath(unit_path)
551
+ replaced = set()
552
+ for name in list(namespace.keys()):
553
+ func = namespace[name]
554
+ if not isinstance(func, types.FunctionType):
555
+ continue
556
+ if os.path.realpath(func.__code__.co_filename) != want:
557
+ continue
558
+ namespace[name] = _make_stub(func)
559
+ replaced.add(name)
560
+ return replaced
561
+
562
+
563
+ def _make_stub(original):
564
+ def stub(*_args, **_kwargs):
565
+ return None
566
+ stub.__name__ = original.__name__
567
+ stub.__wrapped__ = original
568
+ return stub
569
+
570
+
522
571
  def main(argv=None):
523
572
  parser = argparse.ArgumentParser(description="Python 2/3 capture tracer (Loki Modernize M-09)")
524
573
  parser.add_argument("--unit", required=True, help="path to the unit's .py source")
@@ -526,6 +575,8 @@ def main(argv=None):
526
575
  parser.add_argument("--out", required=True, help="output cases.jsonl path")
527
576
  parser.add_argument("--coverage", help="output coverage.json path")
528
577
  parser.add_argument("--boundaries", help="JSON file of declared text/bytes boundaries")
578
+ parser.add_argument("--stub", action="store_true",
579
+ help="no-op ablation: replace every function defined in the unit file with a stub returning None")
529
580
  args = parser.parse_args(argv)
530
581
 
531
582
  unit_path = os.path.abspath(args.unit)
@@ -547,6 +598,10 @@ def main(argv=None):
547
598
  sys.stderr.write("py_capture: failed to load unit %s: %s\n" % (args.unit, exc))
548
599
  return 2
549
600
 
601
+ stubbed = None
602
+ if args.stub:
603
+ stubbed = _stub_unit_functions(namespace, unit_path)
604
+
550
605
  with open(args.cases, "r") as fh:
551
606
  case_lines = [line for line in fh if line.strip()]
552
607
 
@@ -556,6 +611,9 @@ def main(argv=None):
556
611
  spec = json.loads(line)
557
612
  entry = spec["entry"]
558
613
  entries_used.add(entry)
614
+ if stubbed is not None and entry not in stubbed:
615
+ sys.stderr.write("py_capture: --stub could not stub entry %s\n" % entry)
616
+ return 2
559
617
  func = namespace.get(entry)
560
618
  if func is None or not callable(func):
561
619
  sys.stderr.write("py_capture: entry not found or not callable: %s\n" % entry)
@@ -570,17 +628,18 @@ def main(argv=None):
570
628
 
571
629
  not_proven = []
572
630
  bound_names = _bound_names(func, call_args, call_kwargs)
631
+ case_declared = _declared_for_case(declared, spec.get("boundaries"), bound_names, call_args, call_kwargs)
573
632
  for i, name in enumerate(bound_names):
574
633
  if i < len(call_args):
575
- reason = _check_boundary(name, call_args[i], declared)
634
+ reason = _check_boundary(name, call_args[i], case_declared)
576
635
  elif name in call_kwargs:
577
- reason = _check_boundary(name, call_kwargs[name], declared)
636
+ reason = _check_boundary(name, call_kwargs[name], case_declared)
578
637
  else:
579
638
  reason = None
580
639
  if reason:
581
640
  not_proven.append(reason)
582
641
  if outcome["had_result"]:
583
- reason = _check_boundary("return", outcome["result"], declared)
642
+ reason = _check_boundary("return", outcome["result"], case_declared)
584
643
  if reason:
585
644
  not_proven.append(reason)
586
645
  not_proven.extend(_unsupported_not_proven(outcome["result_tag"], "return"))
@@ -88,14 +88,40 @@ _loki_prd_enrich_invoke() {
88
88
  local out=""
89
89
  _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
90
90
  ! type _loki_claude_permission_argv >/dev/null 2>&1 || _loki_claude_permission_argv
91
+ # S8: lean child flags (no user-scope settings/MCP/tools) + ledger line.
92
+ type loki_lean_child_flags >/dev/null 2>&1 \
93
+ || . "$(dirname "${BASH_SOURCE[0]}")/claude-flags.sh" 2>/dev/null || true
94
+ local _pe_argv=("${_LOKI_PERM_ARGV[@]}") _pe_lf _pe_ledger=false
95
+ if type loki_lean_child_flags >/dev/null 2>&1; then
96
+ while IFS= read -r _pe_lf; do
97
+ [ -n "$_pe_lf" ] && _pe_argv+=("$_pe_lf")
98
+ done < <(loki_lean_child_flags enrich 2>/dev/null)
99
+ fi
100
+ if type loki_child_ledger_append >/dev/null 2>&1; then
101
+ _pe_ledger=true
102
+ _pe_argv+=("--output-format" "json")
103
+ fi
91
104
  if command -v timeout >/dev/null 2>&1; then
92
105
  out=$(CAVEMAN_DEFAULT_MODE=off timeout "${LOKI_PRD_ENRICH_TIMEOUT}" \
93
- claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" 2>/dev/null) || rc=$?
106
+ claude "${_pe_argv[@]}" -p "$prompt" 2>/dev/null) || rc=$?
94
107
  else
95
108
  # No coreutils timeout (e.g. bare macOS). Run without it; the model
96
109
  # call is a one-shot and the caller still tolerates failure.
97
110
  out=$(CAVEMAN_DEFAULT_MODE=off \
98
- claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" 2>/dev/null) || rc=$?
111
+ claude "${_pe_argv[@]}" -p "$prompt" 2>/dev/null) || rc=$?
112
+ fi
113
+ if [ "$rc" -eq 0 ] && [ "$_pe_ledger" = true ] && [ -n "$out" ]; then
114
+ loki_child_ledger_append enrich "$out" >/dev/null 2>&1 || true
115
+ out=$(printf '%s' "$out" | python3 -I -c 'import json,sys
116
+ raw = sys.stdin.read()
117
+ try:
118
+ d = json.loads(raw)
119
+ except Exception:
120
+ d = None
121
+ if isinstance(d, list):
122
+ d = next((x for x in reversed(d) if isinstance(x, dict) and x.get("type") == "result"), {})
123
+ # A CLI or stub that ignores --output-format json returns plain text: keep it.
124
+ sys.stdout.write(str(d.get("result", "")) if isinstance(d, dict) else raw)' 2>/dev/null)
99
125
  fi
100
126
  [ "$rc" -ne 0 ] && return 1
101
127
  [ -z "$out" ] && return 1