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
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
# Loki 10 engine P4 push child (ENGINE.md sections 6 and 7).
|
|
3
3
|
#
|
|
4
4
|
# engine10-push.sh push-pr <repo-dir> <branch> <title> <body-file> [--draft] [--base <branch>]
|
|
5
|
-
# engine10-push.sh
|
|
5
|
+
# engine10-push.sh pr-ready <repo-dir> <branch> <body-file> <pr-url> (reused open PR: replace its body, then mark it ready; <pr-url> must be the pinned-owner PR)
|
|
6
|
+
# engine10-push.sh check-origin (exit 0 when the pinned origin is acceptable to push-pr, else the refusal; no network)
|
|
6
7
|
# engine10-push.sh comment <pr-number> <body-file>
|
|
7
8
|
# engine10-push.sh issue-comment [--update] <issue-ref> <body-file>
|
|
8
9
|
# engine10-push.sh status <sha> <pending|success|failure|error> <description>
|
|
@@ -117,6 +118,16 @@ _e10_local_push() {
|
|
|
117
118
|
|
|
118
119
|
_e10_gh() { _loki_with_github_tokens _loki_run_neutral "$_e10_repo" command gh "$@"; }
|
|
119
120
|
|
|
121
|
+
# The one open-PR lookup for push-pr reuse and pr-ready. gh cannot filter --head by owner, so a stranger's fork PR with the
|
|
122
|
+
# same branch name would match; select only a same-repo PR from the pinned owner. Prints the URL, or nothing.
|
|
123
|
+
_e10_find_pr() {
|
|
124
|
+
local owner="${_e10_repo%%/*}"
|
|
125
|
+
case "$owner" in '' | *[!A-Za-z0-9_.-]*) return 1 ;; esac
|
|
126
|
+
_e10_gh pr list --repo "$_e10_repo" --head "$1" --state open \
|
|
127
|
+
--json url,isCrossRepository,headRepositoryOwner,headRefName,state \
|
|
128
|
+
--jq "[.[] | select(.isCrossRepository == false and .state == \"OPEN\" and (.headRepositoryOwner.login | ascii_downcase) == \"$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]')\")] | .[0].url // empty"
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
mode="${1:-}"
|
|
121
132
|
shift || true
|
|
122
133
|
case "$mode" in
|
|
@@ -157,10 +168,9 @@ case "$mode" in
|
|
|
157
168
|
else
|
|
158
169
|
_loki_trusted_push _loki_with_github_tokens "$dir" "$branch" || die "push refused or failed (rc=$?)"
|
|
159
170
|
fi
|
|
160
|
-
url="$(
|
|
161
|
-
|| die "gh pr list failed"
|
|
171
|
+
url="$(_e10_find_pr "$branch")" || die "gh pr list failed"
|
|
162
172
|
_e10_pr_state=reused
|
|
163
|
-
if [ -z "$url" ]
|
|
173
|
+
if [ -z "$url" ]; then
|
|
164
174
|
_e10_pr_state=created
|
|
165
175
|
url="$(_e10_gh pr create --repo "$_e10_repo" --head "$branch" --title "$title" --body-file "$body" "${draft[@]}" "${base[@]}")" \
|
|
166
176
|
|| die "gh pr create failed"
|
|
@@ -169,6 +179,21 @@ case "$mode" in
|
|
|
169
179
|
printf '%s\n' "$_e10_pr_state" # marker line before the URL: reused (open PR found) or created
|
|
170
180
|
printf '%s\n' "$url"
|
|
171
181
|
;;
|
|
182
|
+
pr-ready)
|
|
183
|
+
# M-21 N3: a reused wave PR whose units are all PROVEN keeps its old draft state and body; fix both, same pinned origin and tokens as push-pr.
|
|
184
|
+
[ "$#" -eq 4 ] || die "usage: pr-ready <repo-dir> <branch> <body-file> <pr-url>"
|
|
185
|
+
branch="$2" body="$3" want_url="$4"
|
|
186
|
+
[ -f "$body" ] || die "body file not found: $body"
|
|
187
|
+
git check-ref-format --branch "$branch" >/dev/null 2>&1 || die "invalid branch: $branch"
|
|
188
|
+
case "$branch" in main | master | HEAD) die "refusing to edit a PR for default branch: $branch" ;; esac
|
|
189
|
+
[ -z "$_e10_local" ] || { log_info "local origin: no PR to mark ready (no-op)"; exit 0; }
|
|
190
|
+
url="$(_e10_find_pr "$branch")" || die "gh pr list failed"
|
|
191
|
+
[ -n "$url" ] || die "no open PR from $_e10_repo for branch: $branch"
|
|
192
|
+
[ "$url" = "$want_url" ] || die "refusing: PR $want_url is not the pinned-owner PR for $branch ($url)"
|
|
193
|
+
_e10_gh pr edit "$url" --repo "$_e10_repo" --body-file "$body" >/dev/null || die "gh pr edit failed"
|
|
194
|
+
_e10_gh pr ready "$url" --repo "$_e10_repo" >/dev/null || die "gh pr ready failed"
|
|
195
|
+
printf '%s\n' "$url"
|
|
196
|
+
;;
|
|
172
197
|
comment)
|
|
173
198
|
[ "$#" -eq 2 ] || die "usage: comment <pr-number> <body-file>"
|
|
174
199
|
case "$1" in '' | *[!0-9]*) die "pr number must be digits" ;; esac
|
|
@@ -215,5 +240,5 @@ case "$mode" in
|
|
|
215
240
|
_e10_gh api "repos/$_e10_repo/statuses/$1" -f "state=$2" -f context=loki/deep-verify \
|
|
216
241
|
-f "description=$3" >/dev/null || die "gh api status failed"
|
|
217
242
|
;;
|
|
218
|
-
*) die "usage: engine10-push.sh push-pr|comment|issue-comment|status ..." ;;
|
|
243
|
+
*) die "usage: engine10-push.sh push-pr|pr-ready|comment|issue-comment|status ..." ;;
|
|
219
244
|
esac
|
|
@@ -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"))
|
|
@@ -86,14 +86,42 @@ _loki_prd_enrich_invoke() {
|
|
|
86
86
|
command -v claude >/dev/null 2>&1 || return 1
|
|
87
87
|
local rc=0
|
|
88
88
|
local out=""
|
|
89
|
+
_LOKI_PERM_ARGV=(--dangerously-skip-permissions)
|
|
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
|
|
89
104
|
if command -v timeout >/dev/null 2>&1; then
|
|
90
105
|
out=$(CAVEMAN_DEFAULT_MODE=off timeout "${LOKI_PRD_ENRICH_TIMEOUT}" \
|
|
91
|
-
claude
|
|
106
|
+
claude "${_pe_argv[@]}" -p "$prompt" 2>/dev/null) || rc=$?
|
|
92
107
|
else
|
|
93
108
|
# No coreutils timeout (e.g. bare macOS). Run without it; the model
|
|
94
109
|
# call is a one-shot and the caller still tolerates failure.
|
|
95
110
|
out=$(CAVEMAN_DEFAULT_MODE=off \
|
|
96
|
-
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)
|
|
97
125
|
fi
|
|
98
126
|
[ "$rc" -ne 0 ] && return 1
|
|
99
127
|
[ -z "$out" ] && return 1
|
|
@@ -1455,6 +1455,11 @@ def _collect_journey(loki_dir):
|
|
|
1455
1455
|
},
|
|
1456
1456
|
"waited_s": ws,
|
|
1457
1457
|
}
|
|
1458
|
+
# active_s (measured active human seconds) rides along only when
|
|
1459
|
+
# recorded; absent stays absent, never a fabricated 0.
|
|
1460
|
+
act = iv.get("active_s")
|
|
1461
|
+
if isinstance(act, int) and not isinstance(act, bool) and act >= 0:
|
|
1462
|
+
out["interventions_detail"]["active_s"] = act
|
|
1458
1463
|
|
|
1459
1464
|
# PR state/URL, written by the consent-gated PR step. "prepared" means a PR
|
|
1460
1465
|
# body exists locally and GitHub was NOT mutated.
|
|
@@ -1173,10 +1173,34 @@ def git_head(repo):
|
|
|
1173
1173
|
return out.strip() if out and out.strip() else ""
|
|
1174
1174
|
|
|
1175
1175
|
|
|
1176
|
+
def _sparse_included(repo, paths):
|
|
1177
|
+
"""Set of `paths` git's own sparse matcher includes; None when git cannot answer.
|
|
1178
|
+
|
|
1179
|
+
One `git sparse-checkout check-rules` call for the whole batch: it echoes
|
|
1180
|
+
the paths the patterns include, for cone, non-cone and negation alike.
|
|
1181
|
+
Known limit: a .git directory writable by the subject can drop paths from
|
|
1182
|
+
info/sparse-checkout. That hole predates this check (core.sparseCheckout=true
|
|
1183
|
+
alone was enough).
|
|
1184
|
+
"""
|
|
1185
|
+
enc = [p.encode("utf-8", "surrogateescape") for p in paths]
|
|
1186
|
+
try:
|
|
1187
|
+
r = subprocess.run(
|
|
1188
|
+
["git", "-C", repo or ".", "sparse-checkout", "check-rules", "-z"],
|
|
1189
|
+
input=b"".join(e + b"\0" for e in enc),
|
|
1190
|
+
capture_output=True, timeout=60, env=_scoped_env())
|
|
1191
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
1192
|
+
return None
|
|
1193
|
+
if r.returncode != 0:
|
|
1194
|
+
return None
|
|
1195
|
+
out = set(r.stdout.split(b"\0"))
|
|
1196
|
+
return set(p for p, e in zip(paths, enc) if e in out)
|
|
1197
|
+
|
|
1198
|
+
|
|
1176
1199
|
def git_dirty(repo):
|
|
1177
1200
|
"""True/False, or None when git cannot answer. .loki/ is excluded: the
|
|
1178
1201
|
verifier writes its own artifacts there."""
|
|
1179
|
-
out = _vr_git_args(repo, ["status", "--porcelain"
|
|
1202
|
+
out = _vr_git_args(repo, ["status", "--porcelain", "--untracked-files=all",
|
|
1203
|
+
"--no-renames", "--ignore-submodules=none"])
|
|
1180
1204
|
if out is None:
|
|
1181
1205
|
return None
|
|
1182
1206
|
for line in out.splitlines():
|
|
@@ -1185,6 +1209,90 @@ def git_dirty(repo):
|
|
|
1185
1209
|
path = path.strip('"')
|
|
1186
1210
|
if not (path.startswith(".loki/") or path == ".loki"):
|
|
1187
1211
|
return True
|
|
1212
|
+
# A skip-worktree (S/s) or assume-unchanged (lowercase tag) flag hides edits
|
|
1213
|
+
# from status. A flagged path is dirty only when its content differs from
|
|
1214
|
+
# the index; a skip-worktree path absent on disk is sparse-excluded (clean).
|
|
1215
|
+
flagged = _vr_git_args(repo, ["ls-files", "-v", "-z"])
|
|
1216
|
+
if flagged is None:
|
|
1217
|
+
return None
|
|
1218
|
+
cand = []
|
|
1219
|
+
for ent in flagged.split("\0"):
|
|
1220
|
+
tag, path = ent[:1], ent[2:]
|
|
1221
|
+
if not path or path.startswith(".loki/"):
|
|
1222
|
+
continue
|
|
1223
|
+
if tag in ("S", "s") or tag.islower():
|
|
1224
|
+
cand.append((tag in ("S", "s"), path))
|
|
1225
|
+
if not cand:
|
|
1226
|
+
return False
|
|
1227
|
+
staged = _vr_git_args(repo, ["ls-files", "-s", "-z"])
|
|
1228
|
+
if staged is None:
|
|
1229
|
+
return None
|
|
1230
|
+
blobs = {}
|
|
1231
|
+
for ent in staged.split("\0"):
|
|
1232
|
+
meta, _, p = ent.partition("\t")
|
|
1233
|
+
f = meta.split()
|
|
1234
|
+
if len(f) == 3 and f[2] == "0":
|
|
1235
|
+
blobs[p] = (f[0], f[1])
|
|
1236
|
+
sparse = (_vr_git_args(repo, ["config", "--bool", "core.sparseCheckout"]) or "").strip() == "true"
|
|
1237
|
+
root = repo or "."
|
|
1238
|
+
bad = None
|
|
1239
|
+
todo = []
|
|
1240
|
+
sparse_missing = []
|
|
1241
|
+
for skip, path in cand:
|
|
1242
|
+
full = os.path.join(root, path)
|
|
1243
|
+
mode, want = blobs.get(path, ("", ""))
|
|
1244
|
+
if not os.path.lexists(full):
|
|
1245
|
+
if skip and sparse:
|
|
1246
|
+
# Decided after the loop in one batched git call: clean only when
|
|
1247
|
+
# git's sparse patterns exclude the path. An in-cone path flagged
|
|
1248
|
+
# by hand and deleted is a real deletion; fail closed otherwise.
|
|
1249
|
+
sparse_missing.append(path)
|
|
1250
|
+
continue
|
|
1251
|
+
bad = (skip, path)
|
|
1252
|
+
break
|
|
1253
|
+
if mode == "160000":
|
|
1254
|
+
continue
|
|
1255
|
+
if os.path.islink(full):
|
|
1256
|
+
data = os.readlink(full).encode("utf-8", "surrogateescape")
|
|
1257
|
+
if hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() != want:
|
|
1258
|
+
bad = (skip, path)
|
|
1259
|
+
break
|
|
1260
|
+
continue
|
|
1261
|
+
todo.append((skip, path))
|
|
1262
|
+
if sparse_missing and not bad:
|
|
1263
|
+
included = _sparse_included(repo, sparse_missing)
|
|
1264
|
+
for path in sparse_missing:
|
|
1265
|
+
if included is None or path in included:
|
|
1266
|
+
bad = (True, path)
|
|
1267
|
+
break
|
|
1268
|
+
for i in range(0, len(todo), 200):
|
|
1269
|
+
if bad:
|
|
1270
|
+
break
|
|
1271
|
+
chunk = todo[i:i + 200]
|
|
1272
|
+
out = _vr_git_args(repo, ["hash-object", "--"] + [p for _, p in chunk])
|
|
1273
|
+
if out is None:
|
|
1274
|
+
return None
|
|
1275
|
+
miss = [(s, p) for (s, p), h in zip(chunk, out.split())
|
|
1276
|
+
if h != blobs.get(p, ("", ""))[1]]
|
|
1277
|
+
if miss:
|
|
1278
|
+
# Identical raw bytes mean clean (CRLF in the index plus autocrlf).
|
|
1279
|
+
# The clean filter is not idempotent: the index may hold unfiltered
|
|
1280
|
+
# bytes that a filtered hash of the worktree file never reproduces,
|
|
1281
|
+
# so the filtered hash alone would call such a file dirty.
|
|
1282
|
+
raw = _vr_git_args(repo, ["hash-object", "--no-filters", "--"] + [p for _, p in miss])
|
|
1283
|
+
if raw is None:
|
|
1284
|
+
return None
|
|
1285
|
+
for (skip, path), h in zip(miss, raw.split()):
|
|
1286
|
+
if h != blobs.get(path, ("", ""))[1]:
|
|
1287
|
+
bad = (skip, path)
|
|
1288
|
+
break
|
|
1289
|
+
if bad:
|
|
1290
|
+
skip, path = bad
|
|
1291
|
+
kind, fix = (("skip-worktree", "--no-skip-worktree") if skip
|
|
1292
|
+
else ("assume-unchanged", "--no-assume-unchanged"))
|
|
1293
|
+
sys.stderr.write("dirty: %s is flagged %s and differs from the index, which hides edits from the proof. "
|
|
1294
|
+
"Run: git update-index %s -- %s\n" % (path, kind, fix, path))
|
|
1295
|
+
return True
|
|
1188
1296
|
return False
|
|
1189
1297
|
|
|
1190
1298
|
|
|
@@ -183,7 +183,7 @@ def _cost_value(proof):
|
|
|
183
183
|
|
|
184
184
|
|
|
185
185
|
def _intervention_detail(proof):
|
|
186
|
-
"""(by_type, waited_s) from proof.interventions_detail; None when absent."""
|
|
186
|
+
"""(by_type, waited_s, active_s) from proof.interventions_detail; None when absent."""
|
|
187
187
|
d = _obj(proof.get("interventions_detail"))
|
|
188
188
|
bt = _obj(d.get("by_type"))
|
|
189
189
|
out = {}
|
|
@@ -198,7 +198,9 @@ def _intervention_detail(proof):
|
|
|
198
198
|
any_key = True
|
|
199
199
|
w = d.get("waited_s")
|
|
200
200
|
ws = w if isinstance(w, (int, float)) and not isinstance(w, bool) and w >= 0 else None
|
|
201
|
-
|
|
201
|
+
a = d.get("active_s")
|
|
202
|
+
act = a if isinstance(a, int) and not isinstance(a, bool) and a >= 0 else None
|
|
203
|
+
return (out if any_key else None), ws, act
|
|
202
204
|
|
|
203
205
|
|
|
204
206
|
def _verdict_evidence(proof):
|
|
@@ -229,13 +231,14 @@ def _load_runs(loki_dir):
|
|
|
229
231
|
# Malformed / partial proof: skip, do not fail the trajectory.
|
|
230
232
|
continue
|
|
231
233
|
verdict, false_verified = _verdict_evidence(proof)
|
|
232
|
-
by_type, waited_s = _intervention_detail(proof)
|
|
234
|
+
by_type, waited_s, active_s = _intervention_detail(proof)
|
|
233
235
|
runs.append({
|
|
234
236
|
"verdict": verdict,
|
|
235
237
|
"false_verified": false_verified,
|
|
236
238
|
"cost_usd": _cost_value(proof),
|
|
237
239
|
"interventions_by_type": by_type,
|
|
238
240
|
"waited_s": waited_s,
|
|
241
|
+
"active_s": active_s,
|
|
239
242
|
"run_id": str(proof.get("run_id") or name),
|
|
240
243
|
"generated_at": proof.get("generated_at"),
|
|
241
244
|
"council_pass_rate": _council_pass_value(proof.get("council")),
|
|
@@ -343,6 +346,7 @@ def outcome_effort(series):
|
|
|
343
346
|
by_type = {k: sum(r["interventions_by_type"][k] for r in with_detail) for k in _IV_TYPES}
|
|
344
347
|
all_iv = bool(series) and all(r["interventions"] is not None for r in series)
|
|
345
348
|
all_waited = bool(series) and all(r["waited_s"] is not None for r in series)
|
|
349
|
+
all_active = bool(series) and all(r["active_s"] is not None for r in series)
|
|
346
350
|
return {
|
|
347
351
|
"runs": len(series),
|
|
348
352
|
"accepted": accepted,
|
|
@@ -356,6 +360,8 @@ def outcome_effort(series):
|
|
|
356
360
|
round(sum(r["interventions"] for r in series) / accepted, 4) if accepted and all_iv else None),
|
|
357
361
|
"waited_min_per_accepted": (
|
|
358
362
|
round(sum(r["waited_s"] for r in series) / 60.0 / accepted, 1) if accepted and all_waited else None),
|
|
363
|
+
"active_min_per_accepted": (
|
|
364
|
+
round(sum(r["active_s"] for r in series) / 60.0 / accepted, 1) if accepted and all_active else None),
|
|
359
365
|
}
|
|
360
366
|
|
|
361
367
|
|
|
@@ -376,7 +382,9 @@ def _fmt_outcome_effort(e):
|
|
|
376
382
|
lines.append(" Interventions per accepted: %s" % ("n/a" if ipa is None else ipa))
|
|
377
383
|
w = e.get("waited_min_per_accepted")
|
|
378
384
|
lines.append(" Minutes per accepted: %s" % (
|
|
379
|
-
"n/a" if w is None else "%s (waited, upper bound
|
|
385
|
+
"n/a" if w is None else "%s (waited, upper bound)" % w))
|
|
386
|
+
am = e.get("active_min_per_accepted")
|
|
387
|
+
lines.append(" Active minutes per accepted: %s" % ("n/a (not measured)" if am is None else am))
|
|
380
388
|
return lines
|
|
381
389
|
|
|
382
390
|
|
|
@@ -397,6 +405,7 @@ def compute_trajectory(loki_dir):
|
|
|
397
405
|
"cost_usd": r["cost_usd"],
|
|
398
406
|
"interventions_by_type": r["interventions_by_type"],
|
|
399
407
|
"waited_s": r["waited_s"],
|
|
408
|
+
"active_s": r["active_s"],
|
|
400
409
|
"verdict": r["verdict"],
|
|
401
410
|
"false_verified": r["false_verified"],
|
|
402
411
|
})
|
|
@@ -110,6 +110,11 @@ complexity = os.environ.get("_VA_COMPLEXITY", "standard")
|
|
|
110
110
|
_effort_bump = {"low": "medium", "medium": "high", "high": "xhigh"}
|
|
111
111
|
def _effort(base):
|
|
112
112
|
return _effort_bump.get(base, base) if complexity == "complex" else base
|
|
113
|
+
# Cost S7: the three base voters run on haiku for small/standard work; the
|
|
114
|
+
# complex tier keeps the original opus/sonnet roster. haiku takes no effort knob.
|
|
115
|
+
_lean = complexity != "complex"
|
|
116
|
+
def _model(default):
|
|
117
|
+
return "haiku" if _lean else default
|
|
113
118
|
prd_clause = (
|
|
114
119
|
f"PRD at {prd}. Read it for ground-truth requirements."
|
|
115
120
|
if prd else
|
|
@@ -118,8 +123,7 @@ prd_clause = (
|
|
|
118
123
|
agents = {
|
|
119
124
|
"requirements-verifier": {
|
|
120
125
|
"description": "Verifies the iteration delivered the PRD-required behavior.",
|
|
121
|
-
"model": "opus",
|
|
122
|
-
"effort": _effort("high"),
|
|
126
|
+
"model": _model("opus"),
|
|
123
127
|
"tools": ["Read", "Grep", "Bash"],
|
|
124
128
|
"prompt": (
|
|
125
129
|
f"You are the requirements-verifier for iteration {iter_n}. "
|
|
@@ -131,8 +135,7 @@ agents = {
|
|
|
131
135
|
},
|
|
132
136
|
"test-auditor": {
|
|
133
137
|
"description": "Audits test coverage and pass status for the iteration.",
|
|
134
|
-
"model": "sonnet",
|
|
135
|
-
"effort": _effort("high"),
|
|
138
|
+
"model": _model("sonnet"),
|
|
136
139
|
"tools": ["Read", "Grep", "Bash"],
|
|
137
140
|
"prompt": (
|
|
138
141
|
f"You are the test-auditor for iteration {iter_n}. "
|
|
@@ -144,8 +147,7 @@ agents = {
|
|
|
144
147
|
},
|
|
145
148
|
"convergence-voter": {
|
|
146
149
|
"description": "Checks code churn and progress signals to judge convergence.",
|
|
147
|
-
"model": "sonnet",
|
|
148
|
-
"effort": _effort("medium"),
|
|
150
|
+
"model": _model("sonnet"),
|
|
149
151
|
"tools": ["Read", "Grep", "Bash"],
|
|
150
152
|
"prompt": (
|
|
151
153
|
f"You are the convergence-voter for iteration {iter_n} on tier {tier}. "
|
|
@@ -156,6 +158,9 @@ agents = {
|
|
|
156
158
|
),
|
|
157
159
|
},
|
|
158
160
|
}
|
|
161
|
+
if not _lean:
|
|
162
|
+
for _k, _b in (("requirements-verifier", "high"), ("test-auditor", "high"), ("convergence-voter", "medium")):
|
|
163
|
+
agents[_k]["effort"] = _effort(_b)
|
|
159
164
|
print(json.dumps(agents, separators=(",", ":")))
|
|
160
165
|
'
|
|
161
166
|
return 0
|
|
@@ -289,6 +294,13 @@ loki_council_dispatch_agents() {
|
|
|
289
294
|
local prompt
|
|
290
295
|
prompt=$(printf 'Loki council iteration %s. Run each declared agent against the current workspace, return one finding per agent matching the provided JSON Schema. Be terse, be honest.' "$iteration")
|
|
291
296
|
|
|
297
|
+
# Cost S7: hand the council the PRD path so voters stop reporting "No PRD was
|
|
298
|
+
# provided". A missing/unreadable PRD adds nothing; voters then vote
|
|
299
|
+
# CANNOT_VALIDATE, which never approves.
|
|
300
|
+
if [ -n "$prd_path" ] && [ -r "$prd_path" ]; then
|
|
301
|
+
prompt="${prompt} PRD file: ${prd_path} (read it for ground-truth requirements)."
|
|
302
|
+
fi
|
|
303
|
+
|
|
292
304
|
# Shared dispatch state (declared once, before either the SDK or claude arm).
|
|
293
305
|
local response=""
|
|
294
306
|
local rc=0
|
|
@@ -373,11 +385,56 @@ loki_council_dispatch_agents() {
|
|
|
373
385
|
# (completion-council.sh:2792). Fail-closed: a hung or timed-out council can
|
|
374
386
|
# never become a false COMPLETE; it always degrades to the heuristic path
|
|
375
387
|
# (which has its own timeout + conservative defaults).
|
|
388
|
+
_LOKI_PERM_ARGV=(--dangerously-skip-permissions)
|
|
389
|
+
! type _loki_claude_permission_argv >/dev/null 2>&1 || _loki_claude_permission_argv
|
|
390
|
+
# Cost S7 lean child: council role flags (no hooks/MCP/tools on the main
|
|
391
|
+
# child) and --output-format json so usage lands in the child ledger. The
|
|
392
|
+
# envelope is unwrapped back to the {findings:[...]} object below.
|
|
393
|
+
local _va_lean=() _va_lf
|
|
394
|
+
if type loki_lean_child_flags >/dev/null 2>&1; then
|
|
395
|
+
while IFS= read -r _va_lf; do
|
|
396
|
+
[ -n "$_va_lf" ] && _va_lean+=("$_va_lf")
|
|
397
|
+
done < <(loki_lean_child_flags council 2>/dev/null)
|
|
398
|
+
fi
|
|
399
|
+
local _va_fmt=()
|
|
400
|
+
if loki_claude_flag_supported "--output-format"; then
|
|
401
|
+
_va_fmt=(--output-format json)
|
|
402
|
+
fi
|
|
376
403
|
response=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" \
|
|
377
|
-
env CAVEMAN_DEFAULT_MODE=off claude
|
|
404
|
+
env CAVEMAN_DEFAULT_MODE=off claude "${_LOKI_PERM_ARGV[@]}" \
|
|
405
|
+
${_va_lean[@]+"${_va_lean[@]}"} \
|
|
406
|
+
${_va_fmt[@]+"${_va_fmt[@]}"} \
|
|
378
407
|
-p "$prompt" \
|
|
379
408
|
--agents "$agents_json" \
|
|
380
409
|
--json-schema "$schema_content" 2>"$stderr_log") || rc=$?
|
|
410
|
+
if [ "$rc" -eq 0 ] && [ -n "$response" ] && [ "${#_va_fmt[@]}" -gt 0 ]; then
|
|
411
|
+
if type loki_child_ledger_append >/dev/null 2>&1; then
|
|
412
|
+
loki_child_ledger_append council "$response" >/dev/null 2>&1 || true
|
|
413
|
+
fi
|
|
414
|
+
local _va_unwrapped _va_upy
|
|
415
|
+
if _va_upy="$(_loki_snapshot_py_tool)"; then
|
|
416
|
+
# Unwrap the CLI envelope: structured_output, else a JSON string in
|
|
417
|
+
# result. A bare {findings:[...]} object passes through unchanged. Any
|
|
418
|
+
# other shape leaves the response as-is, so the parser fails closed.
|
|
419
|
+
_va_unwrapped=$(printf '%s' "$response" | "$_va_upy" -I -S -c 'import sys; sys.path[:] = [p for p in sys.path if p not in ("", ".")]; import json
|
|
420
|
+
try:
|
|
421
|
+
e = json.load(sys.stdin)
|
|
422
|
+
if isinstance(e, dict) and "findings" in e:
|
|
423
|
+
p = e
|
|
424
|
+
else:
|
|
425
|
+
p = e.get("structured_output") if isinstance(e, dict) else None
|
|
426
|
+
if not isinstance(p, dict):
|
|
427
|
+
r = e.get("result") if isinstance(e, dict) else None
|
|
428
|
+
p = json.loads(r) if isinstance(r, str) else None
|
|
429
|
+
if isinstance(p, dict):
|
|
430
|
+
sys.stdout.write(json.dumps(p))
|
|
431
|
+
else:
|
|
432
|
+
sys.exit(1)
|
|
433
|
+
except Exception:
|
|
434
|
+
sys.exit(1)' 2>/dev/null) || _va_unwrapped=""
|
|
435
|
+
if [ -n "$_va_unwrapped" ]; then response="$_va_unwrapped"; fi
|
|
436
|
+
fi
|
|
437
|
+
fi
|
|
381
438
|
fi # end claude-dispatch guard (SDK path skips it, already has $response)
|
|
382
439
|
|
|
383
440
|
# Shared fail-closed check for BOTH paths: any non-zero exit (claude/SDK
|