loki-mode 11.15.0 → 11.17.5

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 (119) hide show
  1. package/SKILL.md +3 -3
  2. package/VERSION +1 -1
  3. package/autonomy/grill.sh +27 -3
  4. package/autonomy/hooks/precompact-flush.sh +51 -0
  5. package/autonomy/hooks/validate-bash.sh +941 -13
  6. package/autonomy/lib/chat.sh +116 -7
  7. package/autonomy/lib/claude-flags.sh +157 -0
  8. package/autonomy/lib/claude_permissions.py +60 -0
  9. package/autonomy/lib/decision_record.py +92 -3
  10. package/autonomy/lib/done-recognition.sh +8 -4
  11. package/autonomy/lib/efficiency_cost.py +102 -0
  12. package/autonomy/lib/engine10-push.sh +30 -5
  13. package/autonomy/lib/modernize/py_capture.py +62 -3
  14. package/autonomy/lib/prd-enrich.sh +30 -2
  15. package/autonomy/lib/proof-generator.py +5 -0
  16. package/autonomy/lib/proof-verify.py +109 -1
  17. package/autonomy/lib/trust_trajectory.py +13 -4
  18. package/autonomy/lib/voter-agents.sh +64 -7
  19. package/autonomy/lib/wiki-generator.py +58 -10
  20. package/autonomy/lib/wiki_llm.py +41 -2
  21. package/autonomy/loki +548 -36
  22. package/autonomy/quickstart.sh +21 -2
  23. package/autonomy/run.sh +217 -16
  24. package/autonomy/trigger-server.py +81 -7
  25. package/completions/_loki +3 -0
  26. package/completions/loki.bash +4 -3
  27. package/dashboard/__init__.py +1 -1
  28. package/dashboard/api_start.py +2 -2
  29. package/dashboard/audit.py +204 -17
  30. package/dashboard/build_supervisor.py +2 -0
  31. package/docs/CLI-REFERENCE.md +5 -1
  32. package/docs/EVALUATING.md +3 -3
  33. package/docs/INSTALLATION.md +1 -1
  34. package/docs/environment-variables.md +1 -0
  35. package/docs/trackers.md +6 -0
  36. package/docs/v10/BOARD.md +27 -1
  37. package/docs/v10/CLI-MODERN.md +10 -7
  38. package/docs/v10/DECISIONS.md +16 -0
  39. package/docs/v10/ENGINE.md +2 -1
  40. package/docs/v10/FAILURE-CLASSES.md +77 -0
  41. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  42. package/docs/v10/GUIDE-MODERNIZE.md +9 -4
  43. package/docs/v10/PLAN-V12.md +896 -0
  44. package/docs/v10/PROGRESS.md +11 -0
  45. package/docs/v11/COST-HALF.md +3 -1
  46. package/docs/visual-evidence.md +2 -0
  47. package/loki-ts/data/finding-schema.json +2 -1
  48. package/loki-ts/dist/loki.js +1180 -1112
  49. package/magic/core/debate.py +27 -1
  50. package/mcp/__init__.py +1 -1
  51. package/memory/retrieval.py +15 -3
  52. package/memory/schemas.py +43 -1
  53. package/memory/storage.py +11 -0
  54. package/package.json +2 -2
  55. package/packages/control-plane/dist/server.js +4154 -3551
  56. package/packages/control-plane/src/server/legacy/routes-control.ts +5 -1
  57. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  58. package/providers/claude.sh +7 -3
  59. package/providers/loader.sh +26 -0
  60. package/providers/model_catalog.json +6 -0
  61. package/providers/model_routes.sh +31 -0
  62. package/src/policies/claude_permissions.js +132 -0
  63. package/web-app/dist/assets/{AdminPage-bZlvH4TL.js → AdminPage-C0v2p3jN.js} +1 -1
  64. package/web-app/dist/assets/{Avatar-BZrBstQe.js → Avatar-Cgc0QepK.js} +1 -1
  65. package/web-app/dist/assets/{Badge-Q8l8r4Vq.js → Badge-1X4wQ0sU.js} +1 -1
  66. package/web-app/dist/assets/{Button-B8Bt0A3S.js → Button-CG79n6-I.js} +1 -1
  67. package/web-app/dist/assets/{CockpitPage-CIYExrXa.js → CockpitPage-rSw2BTKk.js} +1 -1
  68. package/web-app/dist/assets/{ComparePage-CBOmHdam.js → ComparePage-B9EVp__S.js} +1 -1
  69. package/web-app/dist/assets/{ErrorBoundary-BKwDPSnK.js → ErrorBoundary-DLQKY8LF.js} +1 -1
  70. package/web-app/dist/assets/{EvidenceReceiptPanel-KqzjphWl.js → EvidenceReceiptPanel-DE0L1L2Q.js} +1 -1
  71. package/web-app/dist/assets/{GitHubIssuesPanel-lF3GkHRv.js → GitHubIssuesPanel-DlKtYrtk.js} +1 -1
  72. package/web-app/dist/assets/{GitHubPRsPanel-BCzipPWD.js → GitHubPRsPanel-C5c5tY3x.js} +1 -1
  73. package/web-app/dist/assets/{HomePage-CIdnkzaN.js → HomePage-ob1natMp.js} +2 -2
  74. package/web-app/dist/assets/{LoginPage-D-178nTT.js → LoginPage-CCQBgvzB.js} +1 -1
  75. package/web-app/dist/assets/{MagicPage-BmHuzMT8.js → MagicPage-CKSuC0Pp.js} +1 -1
  76. package/web-app/dist/assets/{MetricsPage-CPCiyDFn.js → MetricsPage-nq_ygAuG.js} +1 -1
  77. package/web-app/dist/assets/{NotFoundPage-B2T6n1gx.js → NotFoundPage-B6gVLTAn.js} +1 -1
  78. package/web-app/dist/assets/{ProjectPage-DQmpFhb0.js → ProjectPage-pUnvGriO.js} +3 -3
  79. package/web-app/dist/assets/{ProjectsPage-BmugzFEd.js → ProjectsPage-DMFPGLkE.js} +1 -1
  80. package/web-app/dist/assets/{SettingsPage-ZB-RrmgM.js → SettingsPage-DAB_cp87.js} +1 -1
  81. package/web-app/dist/assets/{ShowcasePage-BAhwHt4_.js → ShowcasePage-DTsx_XxI.js} +1 -1
  82. package/web-app/dist/assets/{SystemSettingsPage-Bt7sf2Vu.js → SystemSettingsPage-C2UUqWhW.js} +1 -1
  83. package/web-app/dist/assets/{TeamsPage-B-v5j_8q.js → TeamsPage-C4Sws9U7.js} +1 -1
  84. package/web-app/dist/assets/{TemplatesPage-bdqUL-LG.js → TemplatesPage-B1-79O4n.js} +1 -1
  85. package/web-app/dist/assets/{TerminalOutput-CRHEXBwS.js → TerminalOutput-CDSVt5TB.js} +1 -1
  86. package/web-app/dist/assets/{activity-C-ROkmXt.js → activity-CBEULbio.js} +1 -1
  87. package/web-app/dist/assets/{bell-DbiCBxQA.js → bell-Dp-j77Qo.js} +1 -1
  88. package/web-app/dist/assets/{bot-DsIgE_qr.js → bot-Dh1FlTEJ.js} +1 -1
  89. package/web-app/dist/assets/{check-CkAdmHWP.js → check-C_7N7W4q.js} +1 -1
  90. package/web-app/dist/assets/{chevron-left-CYTLivAo.js → chevron-left-DtZJiu3T.js} +1 -1
  91. package/web-app/dist/assets/{circle-alert-BVIcAsFJ.js → circle-alert-CJK4sNof.js} +1 -1
  92. package/web-app/dist/assets/{clock-Dg3Rq0go.js → clock--0bfAUkP.js} +1 -1
  93. package/web-app/dist/assets/{cloud-CenrqHFl.js → cloud-CRa9nwiN.js} +1 -1
  94. package/web-app/dist/assets/{code-xml-C2mvfoug.js → code-xml-4gRrVGxx.js} +1 -1
  95. package/web-app/dist/assets/{database-BKa0_eCb.js → database-C-A2USGl.js} +1 -1
  96. package/web-app/dist/assets/{dollar-sign-DfelUK1S.js → dollar-sign-BshJt4ML.js} +1 -1
  97. package/web-app/dist/assets/{file-code-corner-CZKWoHVg.js → file-code-corner-Bd--i_IM.js} +1 -1
  98. package/web-app/dist/assets/{file-plus-CZ-m1zMX.js → file-plus-YiNg-aWn.js} +1 -1
  99. package/web-app/dist/assets/{globe-S0kv_GwC.js → globe-CfyHbAbI.js} +1 -1
  100. package/web-app/dist/assets/{hammer-BW1MDdu1.js → hammer-BlGC-yqz.js} +1 -1
  101. package/web-app/dist/assets/{index-ACEYsTc2.js → index-BlCg8mla.js} +3 -3
  102. package/web-app/dist/assets/{layers-BrXHYZ-L.js → layers-DLiA0AcW.js} +1 -1
  103. package/web-app/dist/assets/{loader-circle-DOdhKFx0.js → loader-circle-CkOfS_Q6.js} +1 -1
  104. package/web-app/dist/assets/{lock-BcFTyg8J.js → lock-C5vRE8HM.js} +1 -1
  105. package/web-app/dist/assets/{package-D34cMVQG.js → package-BPzp43h4.js} +1 -1
  106. package/web-app/dist/assets/{plus-CEaMeBxL.js → plus-C4x6sD5J.js} +1 -1
  107. package/web-app/dist/assets/{refresh-cw-D2Rzxr5U.js → refresh-cw-DZn-gB9J.js} +1 -1
  108. package/web-app/dist/assets/{rotate-ccw-C_-EjRLh.js → rotate-ccw-rH63YhnY.js} +1 -1
  109. package/web-app/dist/assets/{scroll-text-BSSCMI_c.js → scroll-text-DTvZMQuh.js} +1 -1
  110. package/web-app/dist/assets/{server-BKvLonWt.js → server-DmwCdhKT.js} +1 -1
  111. package/web-app/dist/assets/{shield-alert-DbbQbPdW.js → shield-alert-Dn5Rtkhx.js} +1 -1
  112. package/web-app/dist/assets/{thumbs-up-NoBD1fUR.js → thumbs-up-D_lHdH-1.js} +1 -1
  113. package/web-app/dist/assets/{trash-2-BXz8rAyd.js → trash-2-CVfeP0Fb.js} +1 -1
  114. package/web-app/dist/assets/{trending-down-D8u535Re.js → trending-down-ouPrjBEm.js} +1 -1
  115. package/web-app/dist/assets/{trending-up-q8k3kgiy.js → trending-up-7H96_KlR.js} +1 -1
  116. package/web-app/dist/assets/{upload-Cp7_zeBI.js → upload-kTTgyfD6.js} +1 -1
  117. package/web-app/dist/assets/{usePolling-CTHVdcuX.js → usePolling-BFIMPvNM.js} +1 -1
  118. package/web-app/dist/assets/{user-DF7bfSf_.js → user-UDo6EQfn.js} +1 -1
  119. package/web-app/dist/index.html +1 -1
@@ -3,7 +3,7 @@
3
3
  # Sourced by autonomy/loki; defines cmd_chat. Free text goes to the provider
4
4
  # in the current mode; slash commands delegate to existing loki commands.
5
5
 
6
- _LOKI_CHAT_SYSTEM_PROMPT="You are running inside Loki Mode chat. Be concise. Prefer small, verifiable changes. Loki commands available to the user: loki start (also --workspace and --deploy), loki quick, loki verify, loki receipt, loki undo."
6
+ _LOKI_CHAT_SYSTEM_PROMPT="You are running inside Loki Mode chat. Be concise. Prefer small, verifiable changes. Loki commands available to the user: loki start (also --workspace and --deploy), loki quick, loki verify, loki receipt, loki undo. During a run, /steer sends a mid-run directive via loki steer (applied at the next plan or implement stage); /pause and /resume control the run, and /answer replies to a blocked run."
7
7
 
8
8
  _chat_loki_bin() {
9
9
  local d="${_LOKI_SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
@@ -41,7 +41,14 @@ _chat_help() {
41
41
  Loki chat commands:
42
42
  /plan read-only mode (no edits)
43
43
  /build edit mode
44
- /run <prd|brief> loki start with this session's model and provider (same run as the shell)
44
+ /run <prd|brief> loki start with this session's model and provider, in the background (alias /start)
45
+ /start <prd|brief> alias of /run
46
+ /status loki status
47
+ /steer <msg> send a mid-run directive via loki steer (shows the "Steer rev N received" ack)
48
+ /stop loki stop
49
+ /pause loki pause (the run halts at the next iteration boundary)
50
+ /resume loki resume
51
+ /answer <text> loki answer --text: answer a BLOCKED run with a follow-up run
45
52
  /workspace <name> <brief> loki start --workspace (multi-repo run, composite receipt)
46
53
  /deploy [--health-url U] [--auto-rollback] loki deploy --execute after you confirm (gated on a VERIFIED receipt)
47
54
  /attach attach to the current or most recent loki start run in this repo
@@ -64,6 +71,103 @@ HLP
64
71
  }
65
72
 
66
73
 
74
+ # /steer: record source=chat and the chat user as actor (D02). Echo loki steer's own
75
+ # "Steer rev N received" ack; never claim receipt it did not print.
76
+ _chat_steer() {
77
+ local loki="$1" msg="$2" out rc=0 first
78
+ case "$msg" in
79
+ ""|help|h) echo "usage: /steer <message> (not help)"; return 0 ;;
80
+ esac
81
+ out="$(LOKI_STEER_SOURCE=chat LOKI_STEER_ACTOR="${LOKI_STEER_ACTOR:-${USER:-chat}}" "$loki" steer -- "$msg" 2>&1)" || rc=$?
82
+ [ -z "$out" ] || printf '%s\n' "$out"
83
+ if [ "$rc" -ne 0 ]; then echo "(exit $rc)"
84
+ elif ! printf '%s\n' "$out" | grep -q 'Steer rev [0-9][0-9]* received'; then
85
+ first="$(printf '%s\n' "$out" | tr -d '\000-\010\013-\037\177' | sed -n '/[^[:space:]]/{p;q;}' | cut -c1-120)"
86
+ echo "Steer not confirmed: ${first:-no confirmation from loki steer}"
87
+ fi
88
+ return 0
89
+ }
90
+
91
+ # Monotonic milliseconds and a ms sleep; both are functions so tests can inject a fake clock.
92
+ # The clock source is chosen once per launch (_CHAT_CLOCK) so every read shares one base:
93
+ # perl clock_gettime, else whole seconds from $SECONDS.
94
+ _chat_clock_pick() {
95
+ if perl -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC -e 'exit 0' >/dev/null 2>&1; then _CHAT_CLOCK=perl; else _CHAT_CLOCK=seconds; fi
96
+ }
97
+ _chat_now_ms() {
98
+ [ -n "${_CHAT_CLOCK:-}" ] || _chat_clock_pick
99
+ if [ "$_CHAT_CLOCK" = perl ]; then
100
+ perl -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC -e 'printf "%d\n", clock_gettime(CLOCK_MONOTONIC) * 1000'
101
+ else
102
+ echo $(( SECONDS * 1000 ))
103
+ fi
104
+ }
105
+ _chat_sleep_ms() {
106
+ local ms="$1"
107
+ [ "$ms" -gt 0 ] 2>/dev/null || return 0
108
+ sleep "$(( ms / 1000 )).$(printf '%03d' $(( ms % 1000 )))"
109
+ }
110
+
111
+ # /run launch: background `loki start`, then print "Run started" only after the same
112
+ # signals `loki status` reads show a live new run (a new engine10 run dir or a live pid file
113
+ # that was not live before). A start that exits first reports its real error and the log path.
114
+ # Returns non-zero when the start is not proven.
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 clock_failed=0 max_iter before_dir before_state
117
+ log="$root/chat-run.log"
118
+ mkdir -p "$root"
119
+ _chat_clock_pick
120
+ start_ms="$(_chat_now_ms)" || start_ms=""
121
+ case "$start_ms" in
122
+ ''|*[!0-9]*)
123
+ echo "Run not started (clock read failed before launch). Retry /run." >&2
124
+ return 1
125
+ ;;
126
+ esac
127
+ before_dir="$(_chat_run_dir)"; before_state="$(_chat_run_state "$before_dir")"
128
+ _chat_run_start "$loki" "$input" < /dev/null > "$log" 2>&1 &
129
+ pid=$!
130
+ local wait_s="${LOKI_CHAT_START_WAIT:-15}"
131
+ case "$wait_s" in ''|*[!0-9]*) wait_s=15 ;; esac
132
+ [ "$wait_s" -ge 1 ] || wait_s=1
133
+ deadline_ms=$(( start_ms + wait_s * 1000 ))
134
+ # Whole-second clock floors, so allow 1s of slack rather than give up early.
135
+ [ "$_CHAT_CLOCK" = perl ] || deadline_ms=$(( deadline_ms + 1000 ))
136
+ max_iter=$(( wait_s * 5 + 10 ))
137
+ while :; do
138
+ iter=$(( iter + 1 ))
139
+ local nd ns
140
+ nd="$(_chat_run_dir)"; ns="$(_chat_run_state "$nd")"
141
+ if [ "$ns" = "running" ] && { { [ "$nd" != "$before_dir" ] && _chat_pidfile_live "$nd/run.pid"; } || { [ "$nd" = "$before_dir" ] && [ "$before_state" != "running" ]; }; }; then
142
+ echo "Run started in the background (pid $pid). Output: $log"
143
+ return 0
144
+ fi
145
+ if ! kill -0 "$pid" 2>/dev/null; then
146
+ wait "$pid" 2>/dev/null || true
147
+ rc="$(sed -n 's/.*(run finished, exit \([0-9][0-9]*\).*/\1/p' "$log" 2>/dev/null | tail -n1)"
148
+ case "$rc" in ''|0) rc=1 ;; esac
149
+ echo "Run did not start (loki start exited $rc before a live run appeared). Log: $log" >&2
150
+ tail -n 5 "$log" 2>/dev/null | cut -c1-200 >&2
151
+ return "$rc"
152
+ fi
153
+ # A failed or non-numeric clock read (perl fork EAGAIN) counts as the deadline: never mix clock bases.
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
157
+ rem_ms=$(( deadline_ms - now_ms ))
158
+ [ "$rem_ms" -gt 0 ] || break
159
+ [ "$iter" -lt "$max_iter" ] || break
160
+ [ "$rem_ms" -le 200 ] || rem_ms=200
161
+ _chat_sleep_ms "$rem_ms"
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
167
+ echo "Run start not confirmed after ${wait_s}s (no live run state; launcher pid $pid still running). Log: $log" >&2
168
+ return 1
169
+ }
170
+
67
171
  # Run a delegate without letting a non-zero exit end the session (autonomy/loki runs under set -e).
68
172
  _chat_do() { local _rc=0; "$@" || _rc=$?; [ "$_rc" -eq 0 ] || echo "(exit $_rc)"; return 0; }
69
173
 
@@ -318,7 +422,7 @@ Question: $line" || true
318
422
  /status) _chat_do "$loki" status; continue ;;
319
423
  /steer)
320
424
  [ -n "$rest" ] || { echo "usage: /steer <message>"; continue; }
321
- _chat_do "$loki" steer "$rest"; continue ;;
425
+ _chat_steer "$loki" "$rest"; continue ;;
322
426
  /stop) _chat_do "$loki" stop; continue ;;
323
427
  /detach) attached=0; echo "Detached."; continue ;;
324
428
  esac
@@ -328,10 +432,10 @@ Question: $line" || true
328
432
  /help) _chat_help ;;
329
433
  /plan) mode="plan"; echo "Plan mode: read-only." ;;
330
434
  /build) mode="build"; echo "Build mode: edits allowed." ;;
331
- /run)
435
+ /run|/start)
332
436
  [ -n "$rest" ] || { echo "usage: /run <prd|brief>"; continue; }
333
- _chat_run_start "$loki" "$rest"
334
- _chat_attach_banner || true ;;
437
+ local _lrc=0; _chat_run_launch "$loki" "$rest" || _lrc=$?
438
+ if [ "$_lrc" -eq 0 ]; then _chat_attach_banner || true; else echo "(exit $_lrc)"; fi ;;
335
439
  /workspace)
336
440
  local _ws_name="${rest%% *}" _ws_brief=""
337
441
  [ "$_ws_name" != "$rest" ] && _ws_brief="${rest#* }"
@@ -344,8 +448,13 @@ Question: $line" || true
344
448
  /status) _chat_do "$loki" status ;;
345
449
  /steer)
346
450
  [ -n "$rest" ] || { echo "usage: /steer <message>"; continue; }
347
- _chat_do "$loki" steer "$rest" ;;
451
+ _chat_steer "$loki" "$rest" ;;
348
452
  /stop) _chat_do "$loki" stop ;;
453
+ /pause) _chat_do "$loki" pause ;;
454
+ /resume) _chat_do "$loki" resume ;;
455
+ /answer)
456
+ [ -n "$rest" ] || { echo "usage: /answer <text>"; continue; }
457
+ _chat_do "$loki" answer --text "$rest" ;;
349
458
  /detach) echo "Not attached." ;;
350
459
  /quick)
351
460
  [ -n "$rest" ] || { echo "usage: /quick <task>"; continue; }
@@ -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`
@@ -972,3 +1059,73 @@ loki_export_subagent_capacity() {
972
1059
  # subprocess tree inherits it, so every subcall site gets the same capacity
973
1060
  # without each one having to remember to call this.
974
1061
  loki_export_subagent_capacity
1062
+
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
+
1116
+ _loki_claude_permission_argv() {
1117
+ _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
1118
+ _loki_permission_warn_unknown_once
1119
+ [ "$(_loki_permission_policy_mode)" = "legacy" ] && return 0
1120
+ local _helper _out _a
1121
+ _helper="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/src/policies/claude_permissions.js"
1122
+ _LOKI_PERM_ARGV=(--permission-mode plan)
1123
+ if ! command -v node >/dev/null 2>&1 || [ ! -f "$_helper" ]; then
1124
+ echo "[loki] WARNING: compiled permission policy: compiler unavailable; using most restrictive mode (plan)" >&2
1125
+ return 0
1126
+ fi
1127
+ _out="$(node "$_helper" "${LOKI_PROJECT_DIR:-$PWD}")" || return 0
1128
+ [ -n "$_out" ] || return 0
1129
+ _LOKI_PERM_ARGV=()
1130
+ while IFS= read -r _a; do _LOKI_PERM_ARGV+=("$_a"); done <<< "$_out"
1131
+ }
@@ -0,0 +1,60 @@
1
+ """P12-26b: Python entry to the shared Claude permission policy.
2
+
3
+ New file: the python launch sites (dashboard, magic) need the same argv as the
4
+ bash and TS routes; this is a thin caller of src/policies/claude_permissions.js
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).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ _LEGACY = ["--dangerously-skip-permissions"]
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"
31
+
32
+
33
+ def claude_permission_argv(project_dir: str | None = None, env: dict | None = None) -> list:
34
+ env = os.environ if env is None else env
35
+ mode = env.get("LOKI_PERMISSION_POLICY", "")
36
+ if mode == "legacy":
37
+ return list(_LEGACY)
38
+ global _unknown_warned
39
+ helper = Path(__file__).resolve().parents[2] / "src" / "policies" / "claude_permissions.js"
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)
46
+ if node is None or not helper.is_file():
47
+ print("[loki] WARNING: compiled permission policy: compiler unavailable; using most restrictive mode (plan)", file=sys.stderr)
48
+ return list(_FAIL_CLOSED)
49
+ target = project_dir or env.get("LOKI_PROJECT_DIR") or os.getcwd()
50
+ try:
51
+ done = subprocess.run([node, str(helper), str(target)], capture_output=True, text=True,
52
+ timeout=30, env=dict(env), check=False)
53
+ except (OSError, subprocess.SubprocessError):
54
+ return list(_FAIL_CLOSED)
55
+ if done.stderr:
56
+ sys.stderr.write(done.stderr)
57
+ args = [a for a in done.stdout.split("\n") if a]
58
+ if done.returncode != 0 or not args:
59
+ return list(_FAIL_CLOSED)
60
+ return args
@@ -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:
@@ -157,13 +157,15 @@ _loki_done_recog_invoke() {
157
157
  local _dr_schema_content _dr_json _dr_obj
158
158
  _dr_schema_content="$(cat "$_dr_schema" 2>/dev/null)" || _dr_schema_content=""
159
159
  if [ -n "$_dr_schema_content" ]; then
160
+ _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
161
+ ! type _loki_claude_permission_argv >/dev/null 2>&1 || _loki_claude_permission_argv
160
162
  if command -v timeout >/dev/null 2>&1; then
161
163
  _dr_json=$(CAVEMAN_DEFAULT_MODE=off timeout "${LOKI_DONE_RECOG_TIMEOUT}" \
162
- claude --dangerously-skip-permissions -p "$prompt" \
164
+ claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" \
163
165
  --json-schema "$_dr_schema_content" --output-format json 2>/dev/null) || _dr_json=""
164
166
  else
165
167
  _dr_json=$(CAVEMAN_DEFAULT_MODE=off \
166
- claude --dangerously-skip-permissions -p "$prompt" \
168
+ claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" \
167
169
  --json-schema "$_dr_schema_content" --output-format json 2>/dev/null) || _dr_json=""
168
170
  fi
169
171
  local _dr_py
@@ -192,12 +194,14 @@ except Exception:
192
194
 
193
195
  local rc=0
194
196
  local out=""
197
+ _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
198
+ ! type _loki_claude_permission_argv >/dev/null 2>&1 || _loki_claude_permission_argv
195
199
  if command -v timeout >/dev/null 2>&1; then
196
200
  out=$(CAVEMAN_DEFAULT_MODE=off timeout "${LOKI_DONE_RECOG_TIMEOUT}" \
197
- claude --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
201
+ claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" 2>/dev/null) || rc=$?
198
202
  else
199
203
  out=$(CAVEMAN_DEFAULT_MODE=off \
200
- claude --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
204
+ claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" 2>/dev/null) || rc=$?
201
205
  fi
202
206
  [ "$rc" -ne 0 ] && return 1
203
207
  [ -z "$out" ] && return 1
@@ -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
  # ---------------------------------------------------------------------------