loki-mode 11.15.0 → 11.17.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/SKILL.md +3 -3
  2. package/VERSION +1 -1
  3. package/autonomy/grill.sh +27 -3
  4. package/autonomy/hooks/precompact-flush.sh +51 -0
  5. package/autonomy/hooks/validate-bash.sh +941 -13
  6. package/autonomy/lib/chat.sh +116 -7
  7. package/autonomy/lib/claude-flags.sh +157 -0
  8. package/autonomy/lib/claude_permissions.py +60 -0
  9. package/autonomy/lib/decision_record.py +92 -3
  10. package/autonomy/lib/done-recognition.sh +8 -4
  11. package/autonomy/lib/efficiency_cost.py +102 -0
  12. package/autonomy/lib/engine10-push.sh +30 -5
  13. package/autonomy/lib/modernize/py_capture.py +62 -3
  14. package/autonomy/lib/prd-enrich.sh +30 -2
  15. package/autonomy/lib/proof-generator.py +5 -0
  16. package/autonomy/lib/proof-verify.py +109 -1
  17. package/autonomy/lib/trust_trajectory.py +13 -4
  18. package/autonomy/lib/voter-agents.sh +64 -7
  19. package/autonomy/lib/wiki-generator.py +58 -10
  20. package/autonomy/lib/wiki_llm.py +41 -2
  21. package/autonomy/loki +548 -36
  22. package/autonomy/quickstart.sh +21 -2
  23. package/autonomy/run.sh +217 -16
  24. package/autonomy/trigger-server.py +81 -7
  25. package/completions/_loki +3 -0
  26. package/completions/loki.bash +4 -3
  27. package/dashboard/__init__.py +1 -1
  28. package/dashboard/api_start.py +2 -2
  29. package/dashboard/audit.py +204 -17
  30. package/dashboard/build_supervisor.py +2 -0
  31. package/docs/CLI-REFERENCE.md +5 -1
  32. package/docs/EVALUATING.md +3 -3
  33. package/docs/INSTALLATION.md +1 -1
  34. package/docs/environment-variables.md +1 -0
  35. package/docs/trackers.md +6 -0
  36. package/docs/v10/BOARD.md +27 -1
  37. package/docs/v10/CLI-MODERN.md +10 -7
  38. package/docs/v10/DECISIONS.md +16 -0
  39. package/docs/v10/ENGINE.md +2 -1
  40. package/docs/v10/FAILURE-CLASSES.md +77 -0
  41. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  42. package/docs/v10/GUIDE-MODERNIZE.md +9 -4
  43. package/docs/v10/PLAN-V12.md +896 -0
  44. package/docs/v10/PROGRESS.md +11 -0
  45. package/docs/v11/COST-HALF.md +3 -1
  46. package/docs/visual-evidence.md +2 -0
  47. package/loki-ts/data/finding-schema.json +2 -1
  48. package/loki-ts/dist/loki.js +1180 -1112
  49. package/magic/core/debate.py +27 -1
  50. package/mcp/__init__.py +1 -1
  51. package/memory/retrieval.py +15 -3
  52. package/memory/schemas.py +43 -1
  53. package/memory/storage.py +11 -0
  54. package/package.json +2 -2
  55. package/packages/control-plane/dist/server.js +4154 -3551
  56. package/packages/control-plane/src/server/legacy/routes-control.ts +5 -1
  57. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  58. package/providers/claude.sh +7 -3
  59. package/providers/loader.sh +26 -0
  60. package/providers/model_catalog.json +6 -0
  61. package/providers/model_routes.sh +31 -0
  62. package/src/policies/claude_permissions.js +132 -0
  63. package/web-app/dist/assets/{AdminPage-bZlvH4TL.js → AdminPage-C0v2p3jN.js} +1 -1
  64. package/web-app/dist/assets/{Avatar-BZrBstQe.js → Avatar-Cgc0QepK.js} +1 -1
  65. package/web-app/dist/assets/{Badge-Q8l8r4Vq.js → Badge-1X4wQ0sU.js} +1 -1
  66. package/web-app/dist/assets/{Button-B8Bt0A3S.js → Button-CG79n6-I.js} +1 -1
  67. package/web-app/dist/assets/{CockpitPage-CIYExrXa.js → CockpitPage-rSw2BTKk.js} +1 -1
  68. package/web-app/dist/assets/{ComparePage-CBOmHdam.js → ComparePage-B9EVp__S.js} +1 -1
  69. package/web-app/dist/assets/{ErrorBoundary-BKwDPSnK.js → ErrorBoundary-DLQKY8LF.js} +1 -1
  70. package/web-app/dist/assets/{EvidenceReceiptPanel-KqzjphWl.js → EvidenceReceiptPanel-DE0L1L2Q.js} +1 -1
  71. package/web-app/dist/assets/{GitHubIssuesPanel-lF3GkHRv.js → GitHubIssuesPanel-DlKtYrtk.js} +1 -1
  72. package/web-app/dist/assets/{GitHubPRsPanel-BCzipPWD.js → GitHubPRsPanel-C5c5tY3x.js} +1 -1
  73. package/web-app/dist/assets/{HomePage-CIdnkzaN.js → HomePage-ob1natMp.js} +2 -2
  74. package/web-app/dist/assets/{LoginPage-D-178nTT.js → LoginPage-CCQBgvzB.js} +1 -1
  75. package/web-app/dist/assets/{MagicPage-BmHuzMT8.js → MagicPage-CKSuC0Pp.js} +1 -1
  76. package/web-app/dist/assets/{MetricsPage-CPCiyDFn.js → MetricsPage-nq_ygAuG.js} +1 -1
  77. package/web-app/dist/assets/{NotFoundPage-B2T6n1gx.js → NotFoundPage-B6gVLTAn.js} +1 -1
  78. package/web-app/dist/assets/{ProjectPage-DQmpFhb0.js → ProjectPage-pUnvGriO.js} +3 -3
  79. package/web-app/dist/assets/{ProjectsPage-BmugzFEd.js → ProjectsPage-DMFPGLkE.js} +1 -1
  80. package/web-app/dist/assets/{SettingsPage-ZB-RrmgM.js → SettingsPage-DAB_cp87.js} +1 -1
  81. package/web-app/dist/assets/{ShowcasePage-BAhwHt4_.js → ShowcasePage-DTsx_XxI.js} +1 -1
  82. package/web-app/dist/assets/{SystemSettingsPage-Bt7sf2Vu.js → SystemSettingsPage-C2UUqWhW.js} +1 -1
  83. package/web-app/dist/assets/{TeamsPage-B-v5j_8q.js → TeamsPage-C4Sws9U7.js} +1 -1
  84. package/web-app/dist/assets/{TemplatesPage-bdqUL-LG.js → TemplatesPage-B1-79O4n.js} +1 -1
  85. package/web-app/dist/assets/{TerminalOutput-CRHEXBwS.js → TerminalOutput-CDSVt5TB.js} +1 -1
  86. package/web-app/dist/assets/{activity-C-ROkmXt.js → activity-CBEULbio.js} +1 -1
  87. package/web-app/dist/assets/{bell-DbiCBxQA.js → bell-Dp-j77Qo.js} +1 -1
  88. package/web-app/dist/assets/{bot-DsIgE_qr.js → bot-Dh1FlTEJ.js} +1 -1
  89. package/web-app/dist/assets/{check-CkAdmHWP.js → check-C_7N7W4q.js} +1 -1
  90. package/web-app/dist/assets/{chevron-left-CYTLivAo.js → chevron-left-DtZJiu3T.js} +1 -1
  91. package/web-app/dist/assets/{circle-alert-BVIcAsFJ.js → circle-alert-CJK4sNof.js} +1 -1
  92. package/web-app/dist/assets/{clock-Dg3Rq0go.js → clock--0bfAUkP.js} +1 -1
  93. package/web-app/dist/assets/{cloud-CenrqHFl.js → cloud-CRa9nwiN.js} +1 -1
  94. package/web-app/dist/assets/{code-xml-C2mvfoug.js → code-xml-4gRrVGxx.js} +1 -1
  95. package/web-app/dist/assets/{database-BKa0_eCb.js → database-C-A2USGl.js} +1 -1
  96. package/web-app/dist/assets/{dollar-sign-DfelUK1S.js → dollar-sign-BshJt4ML.js} +1 -1
  97. package/web-app/dist/assets/{file-code-corner-CZKWoHVg.js → file-code-corner-Bd--i_IM.js} +1 -1
  98. package/web-app/dist/assets/{file-plus-CZ-m1zMX.js → file-plus-YiNg-aWn.js} +1 -1
  99. package/web-app/dist/assets/{globe-S0kv_GwC.js → globe-CfyHbAbI.js} +1 -1
  100. package/web-app/dist/assets/{hammer-BW1MDdu1.js → hammer-BlGC-yqz.js} +1 -1
  101. package/web-app/dist/assets/{index-ACEYsTc2.js → index-BlCg8mla.js} +3 -3
  102. package/web-app/dist/assets/{layers-BrXHYZ-L.js → layers-DLiA0AcW.js} +1 -1
  103. package/web-app/dist/assets/{loader-circle-DOdhKFx0.js → loader-circle-CkOfS_Q6.js} +1 -1
  104. package/web-app/dist/assets/{lock-BcFTyg8J.js → lock-C5vRE8HM.js} +1 -1
  105. package/web-app/dist/assets/{package-D34cMVQG.js → package-BPzp43h4.js} +1 -1
  106. package/web-app/dist/assets/{plus-CEaMeBxL.js → plus-C4x6sD5J.js} +1 -1
  107. package/web-app/dist/assets/{refresh-cw-D2Rzxr5U.js → refresh-cw-DZn-gB9J.js} +1 -1
  108. package/web-app/dist/assets/{rotate-ccw-C_-EjRLh.js → rotate-ccw-rH63YhnY.js} +1 -1
  109. package/web-app/dist/assets/{scroll-text-BSSCMI_c.js → scroll-text-DTvZMQuh.js} +1 -1
  110. package/web-app/dist/assets/{server-BKvLonWt.js → server-DmwCdhKT.js} +1 -1
  111. package/web-app/dist/assets/{shield-alert-DbbQbPdW.js → shield-alert-Dn5Rtkhx.js} +1 -1
  112. package/web-app/dist/assets/{thumbs-up-NoBD1fUR.js → thumbs-up-D_lHdH-1.js} +1 -1
  113. package/web-app/dist/assets/{trash-2-BXz8rAyd.js → trash-2-CVfeP0Fb.js} +1 -1
  114. package/web-app/dist/assets/{trending-down-D8u535Re.js → trending-down-ouPrjBEm.js} +1 -1
  115. package/web-app/dist/assets/{trending-up-q8k3kgiy.js → trending-up-7H96_KlR.js} +1 -1
  116. package/web-app/dist/assets/{upload-Cp7_zeBI.js → upload-kTTgyfD6.js} +1 -1
  117. package/web-app/dist/assets/{usePolling-CTHVdcuX.js → usePolling-BFIMPvNM.js} +1 -1
  118. package/web-app/dist/assets/{user-DF7bfSf_.js → user-UDo6EQfn.js} +1 -1
  119. package/web-app/dist/index.html +1 -1
@@ -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 check-origin (exit 0 when the pinned origin is acceptable to push-pr, else the refusal; no network)
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="$(_e10_gh pr list --repo "$_e10_repo" --head "$branch" --state open --json url --jq '.[0].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" ] || [ "$url" = "null" ]; then
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], declared)
634
+ reason = _check_boundary(name, call_args[i], case_declared)
576
635
  elif name in call_kwargs:
577
- reason = _check_boundary(name, call_kwargs[name], declared)
636
+ reason = _check_boundary(name, call_kwargs[name], case_declared)
578
637
  else:
579
638
  reason = None
580
639
  if reason:
581
640
  not_proven.append(reason)
582
641
  if outcome["had_result"]:
583
- reason = _check_boundary("return", outcome["result"], declared)
642
+ reason = _check_boundary("return", outcome["result"], case_declared)
584
643
  if reason:
585
644
  not_proven.append(reason)
586
645
  not_proven.extend(_unsupported_not_proven(outcome["result_tag"], "return"))
@@ -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 --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
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 --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
111
+ claude "${_pe_argv[@]}" -p "$prompt" 2>/dev/null) || rc=$?
112
+ fi
113
+ if [ "$rc" -eq 0 ] && [ "$_pe_ledger" = true ] && [ -n "$out" ]; then
114
+ loki_child_ledger_append enrich "$out" >/dev/null 2>&1 || true
115
+ out=$(printf '%s' "$out" | python3 -I -c 'import json,sys
116
+ raw = sys.stdin.read()
117
+ try:
118
+ d = json.loads(raw)
119
+ except Exception:
120
+ d = None
121
+ if isinstance(d, list):
122
+ d = next((x for x in reversed(d) if isinstance(x, dict) and x.get("type") == "result"), {})
123
+ # A CLI or stub that ignores --output-format json returns plain text: keep it.
124
+ sys.stdout.write(str(d.get("result", "")) if isinstance(d, dict) else raw)' 2>/dev/null)
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
- return (out if any_key else None), ws
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; active minutes are not measured)" % w))
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 --dangerously-skip-permissions \
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