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.
- package/SKILL.md +3 -3
- package/VERSION +1 -1
- package/autonomy/grill.sh +27 -3
- package/autonomy/hooks/precompact-flush.sh +51 -0
- package/autonomy/hooks/validate-bash.sh +941 -13
- package/autonomy/lib/chat.sh +116 -7
- package/autonomy/lib/claude-flags.sh +157 -0
- package/autonomy/lib/claude_permissions.py +60 -0
- package/autonomy/lib/decision_record.py +92 -3
- package/autonomy/lib/done-recognition.sh +8 -4
- package/autonomy/lib/efficiency_cost.py +102 -0
- package/autonomy/lib/engine10-push.sh +30 -5
- package/autonomy/lib/modernize/py_capture.py +62 -3
- package/autonomy/lib/prd-enrich.sh +30 -2
- package/autonomy/lib/proof-generator.py +5 -0
- package/autonomy/lib/proof-verify.py +109 -1
- package/autonomy/lib/trust_trajectory.py +13 -4
- package/autonomy/lib/voter-agents.sh +64 -7
- package/autonomy/lib/wiki-generator.py +58 -10
- package/autonomy/lib/wiki_llm.py +41 -2
- package/autonomy/loki +548 -36
- package/autonomy/quickstart.sh +21 -2
- package/autonomy/run.sh +217 -16
- package/autonomy/trigger-server.py +81 -7
- package/completions/_loki +3 -0
- package/completions/loki.bash +4 -3
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_start.py +2 -2
- package/dashboard/audit.py +204 -17
- package/dashboard/build_supervisor.py +2 -0
- package/docs/CLI-REFERENCE.md +5 -1
- package/docs/EVALUATING.md +3 -3
- package/docs/INSTALLATION.md +1 -1
- package/docs/environment-variables.md +1 -0
- package/docs/trackers.md +6 -0
- package/docs/v10/BOARD.md +27 -1
- package/docs/v10/CLI-MODERN.md +10 -7
- package/docs/v10/DECISIONS.md +16 -0
- package/docs/v10/ENGINE.md +2 -1
- package/docs/v10/FAILURE-CLASSES.md +77 -0
- package/docs/v10/FOUNDER-QUEUE.md +1 -0
- package/docs/v10/GUIDE-MODERNIZE.md +9 -4
- package/docs/v10/PLAN-V12.md +896 -0
- package/docs/v10/PROGRESS.md +11 -0
- package/docs/v11/COST-HALF.md +3 -1
- package/docs/visual-evidence.md +2 -0
- package/loki-ts/data/finding-schema.json +2 -1
- package/loki-ts/dist/loki.js +1180 -1112
- package/magic/core/debate.py +27 -1
- package/mcp/__init__.py +1 -1
- package/memory/retrieval.py +15 -3
- package/memory/schemas.py +43 -1
- package/memory/storage.py +11 -0
- package/package.json +2 -2
- package/packages/control-plane/dist/server.js +4154 -3551
- package/packages/control-plane/src/server/legacy/routes-control.ts +5 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +7 -3
- package/providers/loader.sh +26 -0
- package/providers/model_catalog.json +6 -0
- package/providers/model_routes.sh +31 -0
- package/src/policies/claude_permissions.js +132 -0
- package/web-app/dist/assets/{AdminPage-bZlvH4TL.js → AdminPage-C0v2p3jN.js} +1 -1
- package/web-app/dist/assets/{Avatar-BZrBstQe.js → Avatar-Cgc0QepK.js} +1 -1
- package/web-app/dist/assets/{Badge-Q8l8r4Vq.js → Badge-1X4wQ0sU.js} +1 -1
- package/web-app/dist/assets/{Button-B8Bt0A3S.js → Button-CG79n6-I.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-CIYExrXa.js → CockpitPage-rSw2BTKk.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CBOmHdam.js → ComparePage-B9EVp__S.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BKwDPSnK.js → ErrorBoundary-DLQKY8LF.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-KqzjphWl.js → EvidenceReceiptPanel-DE0L1L2Q.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-lF3GkHRv.js → GitHubIssuesPanel-DlKtYrtk.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BCzipPWD.js → GitHubPRsPanel-C5c5tY3x.js} +1 -1
- package/web-app/dist/assets/{HomePage-CIdnkzaN.js → HomePage-ob1natMp.js} +2 -2
- package/web-app/dist/assets/{LoginPage-D-178nTT.js → LoginPage-CCQBgvzB.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BmHuzMT8.js → MagicPage-CKSuC0Pp.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-CPCiyDFn.js → MetricsPage-nq_ygAuG.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-B2T6n1gx.js → NotFoundPage-B6gVLTAn.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-DQmpFhb0.js → ProjectPage-pUnvGriO.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-BmugzFEd.js → ProjectsPage-DMFPGLkE.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-ZB-RrmgM.js → SettingsPage-DAB_cp87.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-BAhwHt4_.js → ShowcasePage-DTsx_XxI.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-Bt7sf2Vu.js → SystemSettingsPage-C2UUqWhW.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-B-v5j_8q.js → TeamsPage-C4Sws9U7.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-bdqUL-LG.js → TemplatesPage-B1-79O4n.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CRHEXBwS.js → TerminalOutput-CDSVt5TB.js} +1 -1
- package/web-app/dist/assets/{activity-C-ROkmXt.js → activity-CBEULbio.js} +1 -1
- package/web-app/dist/assets/{bell-DbiCBxQA.js → bell-Dp-j77Qo.js} +1 -1
- package/web-app/dist/assets/{bot-DsIgE_qr.js → bot-Dh1FlTEJ.js} +1 -1
- package/web-app/dist/assets/{check-CkAdmHWP.js → check-C_7N7W4q.js} +1 -1
- package/web-app/dist/assets/{chevron-left-CYTLivAo.js → chevron-left-DtZJiu3T.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BVIcAsFJ.js → circle-alert-CJK4sNof.js} +1 -1
- package/web-app/dist/assets/{clock-Dg3Rq0go.js → clock--0bfAUkP.js} +1 -1
- package/web-app/dist/assets/{cloud-CenrqHFl.js → cloud-CRa9nwiN.js} +1 -1
- package/web-app/dist/assets/{code-xml-C2mvfoug.js → code-xml-4gRrVGxx.js} +1 -1
- package/web-app/dist/assets/{database-BKa0_eCb.js → database-C-A2USGl.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-DfelUK1S.js → dollar-sign-BshJt4ML.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CZKWoHVg.js → file-code-corner-Bd--i_IM.js} +1 -1
- package/web-app/dist/assets/{file-plus-CZ-m1zMX.js → file-plus-YiNg-aWn.js} +1 -1
- package/web-app/dist/assets/{globe-S0kv_GwC.js → globe-CfyHbAbI.js} +1 -1
- package/web-app/dist/assets/{hammer-BW1MDdu1.js → hammer-BlGC-yqz.js} +1 -1
- package/web-app/dist/assets/{index-ACEYsTc2.js → index-BlCg8mla.js} +3 -3
- package/web-app/dist/assets/{layers-BrXHYZ-L.js → layers-DLiA0AcW.js} +1 -1
- package/web-app/dist/assets/{loader-circle-DOdhKFx0.js → loader-circle-CkOfS_Q6.js} +1 -1
- package/web-app/dist/assets/{lock-BcFTyg8J.js → lock-C5vRE8HM.js} +1 -1
- package/web-app/dist/assets/{package-D34cMVQG.js → package-BPzp43h4.js} +1 -1
- package/web-app/dist/assets/{plus-CEaMeBxL.js → plus-C4x6sD5J.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-D2Rzxr5U.js → refresh-cw-DZn-gB9J.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-C_-EjRLh.js → rotate-ccw-rH63YhnY.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BSSCMI_c.js → scroll-text-DTvZMQuh.js} +1 -1
- package/web-app/dist/assets/{server-BKvLonWt.js → server-DmwCdhKT.js} +1 -1
- package/web-app/dist/assets/{shield-alert-DbbQbPdW.js → shield-alert-Dn5Rtkhx.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-NoBD1fUR.js → thumbs-up-D_lHdH-1.js} +1 -1
- package/web-app/dist/assets/{trash-2-BXz8rAyd.js → trash-2-CVfeP0Fb.js} +1 -1
- package/web-app/dist/assets/{trending-down-D8u535Re.js → trending-down-ouPrjBEm.js} +1 -1
- package/web-app/dist/assets/{trending-up-q8k3kgiy.js → trending-up-7H96_KlR.js} +1 -1
- package/web-app/dist/assets/{upload-Cp7_zeBI.js → upload-kTTgyfD6.js} +1 -1
- package/web-app/dist/assets/{usePolling-CTHVdcuX.js → usePolling-BFIMPvNM.js} +1 -1
- package/web-app/dist/assets/{user-DF7bfSf_.js → user-UDo6EQfn.js} +1 -1
- package/web-app/dist/index.html +1 -1
package/autonomy/lib/chat.sh
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
201
|
+
claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" 2>/dev/null) || rc=$?
|
|
198
202
|
else
|
|
199
203
|
out=$(CAVEMAN_DEFAULT_MODE=off \
|
|
200
|
-
claude
|
|
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
|
# ---------------------------------------------------------------------------
|