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.
- package/SKILL.md +3 -3
- package/VERSION +1 -1
- package/autonomy/grill.sh +24 -2
- package/autonomy/hooks/precompact-flush.sh +51 -0
- package/autonomy/hooks/validate-bash.sh +895 -5
- package/autonomy/lib/chat.sh +8 -3
- package/autonomy/lib/claude-flags.sh +143 -6
- package/autonomy/lib/claude_permissions.py +24 -4
- package/autonomy/lib/decision_record.py +92 -3
- package/autonomy/lib/efficiency_cost.py +102 -0
- package/autonomy/lib/modernize/py_capture.py +62 -3
- package/autonomy/lib/prd-enrich.sh +28 -2
- package/autonomy/lib/voter-agents.sh +61 -6
- package/autonomy/lib/wiki-generator.py +58 -10
- package/autonomy/lib/wiki_llm.py +41 -2
- package/autonomy/loki +391 -28
- package/autonomy/quickstart.sh +1 -1
- package/autonomy/run.sh +201 -10
- package/autonomy/trigger-server.py +70 -2
- package/completions/_loki +3 -0
- package/completions/loki.bash +2 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +204 -17
- package/docs/CLI-REFERENCE.md +4 -1
- package/docs/EVALUATING.md +2 -2
- package/docs/INSTALLATION.md +1 -1
- package/docs/environment-variables.md +1 -0
- package/docs/trackers.md +6 -0
- package/docs/v10/BOARD.md +7 -4
- package/docs/v10/CLI-MODERN.md +8 -5
- package/docs/v10/DECISIONS.md +31 -1
- package/docs/v10/ENGINE.md +1 -0
- package/docs/v10/FAILURE-CLASSES.md +60 -1
- 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 +10 -0
- package/docs/v11/COST-HALF.md +3 -1
- package/docs/visual-evidence.md +2 -0
- package/loki-ts/dist/loki.js +1104 -1057
- 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 +989 -693
- 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 +1 -0
- package/providers/loader.sh +26 -0
- package/providers/model_routes.sh +31 -0
- package/src/policies/claude_permissions.js +20 -2
- package/web-app/dist/assets/{AdminPage-OVzN6eLm.js → AdminPage-CCedYmn3.js} +1 -1
- package/web-app/dist/assets/{Avatar-hCCrxrCZ.js → Avatar-DMrDsuGe.js} +1 -1
- package/web-app/dist/assets/{Badge-CDcpkhbJ.js → Badge-K4Ui2tJz.js} +1 -1
- package/web-app/dist/assets/{Button-8mwWlhEu.js → Button-Bhjts5QZ.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-D8cf3u22.js → CockpitPage-Be7sEVqh.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CIgkbDRY.js → ComparePage-D4ty3jiF.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-99kJH9g2.js → ErrorBoundary-Cmt2x_mL.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-C9fj32eS.js → EvidenceReceiptPanel-D2IXLnp9.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-De43YQ_C.js → GitHubIssuesPanel-CyXitr6h.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-7g7ZTnkx.js → GitHubPRsPanel-RRM3H-xd.js} +1 -1
- package/web-app/dist/assets/{HomePage-CcNA24ny.js → HomePage-CMtrrgfs.js} +2 -2
- package/web-app/dist/assets/{LoginPage-BYjlb-IK.js → LoginPage-BrzNhnMJ.js} +1 -1
- package/web-app/dist/assets/{MagicPage-C0Dwt9Na.js → MagicPage-D7VynOKM.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-jDGmdcgg.js → MetricsPage-DJ-gDbfP.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-C_ZO8tLE.js → NotFoundPage-BJh0XVTj.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-B5wnurfQ.js → ProjectPage-CAodVUpE.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage--EyKt5-D.js → ProjectsPage-m3siS0Rf.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-cB0lnfti.js → SettingsPage-CFGgjOvy.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-Dl16FDkK.js → ShowcasePage-Dd9tpSKJ.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-BmAXd4V2.js → SystemSettingsPage-DXd0PtTI.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-BFkWVOZe.js → TeamsPage-C5KaLIGW.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-Bf2kmEUM.js → TemplatesPage-CDbzE0ZZ.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-BItv9Wa6.js → TerminalOutput-CKG79r_-.js} +1 -1
- package/web-app/dist/assets/{activity-Df2Awg6I.js → activity-BaQQRhDp.js} +1 -1
- package/web-app/dist/assets/{bell-D-1ztTsJ.js → bell-OeFqvU4e.js} +1 -1
- package/web-app/dist/assets/{bot-A4qgZsKi.js → bot-CSxaA0KU.js} +1 -1
- package/web-app/dist/assets/{check-BPfFpF0c.js → check-DJml2Xdn.js} +1 -1
- package/web-app/dist/assets/{chevron-left-CfSJvmDi.js → chevron-left-DGnt-DtK.js} +1 -1
- package/web-app/dist/assets/{circle-alert-wx2d1dzb.js → circle-alert-BV5rVL7K.js} +1 -1
- package/web-app/dist/assets/{clock-pQMai6l-.js → clock-CZ7SvULz.js} +1 -1
- package/web-app/dist/assets/{cloud-DWL470r6.js → cloud-lkIzrZps.js} +1 -1
- package/web-app/dist/assets/{code-xml-BaEQpQLZ.js → code-xml-DzA2wL8F.js} +1 -1
- package/web-app/dist/assets/{database-h-N7WkQN.js → database-CLFG5upC.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-B5oYiD27.js → dollar-sign-DgWePGsv.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-fbTtTy0o.js → file-code-corner-DGQX2bmv.js} +1 -1
- package/web-app/dist/assets/{file-plus-PaF6HMo8.js → file-plus-LkaXeYd6.js} +1 -1
- package/web-app/dist/assets/{globe-Ct-GlWID.js → globe-rSsEzPYv.js} +1 -1
- package/web-app/dist/assets/{hammer-C1oZFX51.js → hammer-CPogONt9.js} +1 -1
- package/web-app/dist/assets/{index-C2egrRhz.js → index-DpBLuMuz.js} +3 -3
- package/web-app/dist/assets/{layers-Dt5ZK4v2.js → layers-CwFaqidn.js} +1 -1
- package/web-app/dist/assets/{loader-circle-DrpMziic.js → loader-circle-C43izEm5.js} +1 -1
- package/web-app/dist/assets/{lock-DOez8MmK.js → lock-Bc_Uqxpp.js} +1 -1
- package/web-app/dist/assets/{package-ClGIkerr.js → package-BM25lcR-.js} +1 -1
- package/web-app/dist/assets/{plus-C004i9ua.js → plus-D0xoiY6N.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-Ddfk-7Sy.js → refresh-cw-COI1R5Oo.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-Bu1KgDjS.js → rotate-ccw-BoNZx4xe.js} +1 -1
- package/web-app/dist/assets/{scroll-text-3q1lW5pm.js → scroll-text-Cakp8XHd.js} +1 -1
- package/web-app/dist/assets/{server-pd6x4HW1.js → server-CjfIlPmE.js} +1 -1
- package/web-app/dist/assets/{shield-alert-B9jGJepT.js → shield-alert-BilhziJF.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-Cdcc9nQf.js → thumbs-up-CglMrtrt.js} +1 -1
- package/web-app/dist/assets/{trash-2-foSojXHK.js → trash-2-B6zVvnoG.js} +1 -1
- package/web-app/dist/assets/{trending-down-CTTiqlJ1.js → trending-down-BVhwD9bt.js} +1 -1
- package/web-app/dist/assets/{trending-up-B_MgmRAx.js → trending-up-CVJ1ox6c.js} +1 -1
- package/web-app/dist/assets/{upload-g6BfALzG.js → upload-DmuR09WL.js} +1 -1
- package/web-app/dist/assets/{usePolling-DrHwvzuh.js → usePolling-BTALfO91.js} +1 -1
- package/web-app/dist/assets/{user-COp75m7l.js → user-CRdHMZNH.js} +1 -1
- package/web-app/dist/index.html +1 -1
package/autonomy/lib/chat.sh
CHANGED
|
@@ -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
|
-
|
|
155
|
-
|
|
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:
|
|
977
|
-
# --permission-mode / --allowedTools / --disallowedTools compiled from the task policy
|
|
978
|
-
#
|
|
979
|
-
#
|
|
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
|
-
|
|
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:
|
|
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.
|
|
6
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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],
|
|
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],
|
|
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"],
|
|
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 "${
|
|
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 "${
|
|
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
|