loki-mode 7.128.1 → 7.129.0

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 CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.128.1
6
+ # Loki Mode v7.129.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -408,4 +408,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
408
408
 
409
409
  ---
410
410
 
411
- **v7.128.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.129.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.128.1
1
+ 7.129.0
@@ -296,7 +296,47 @@ Respond ONLY with a valid JSON object. No markdown fencing."
296
296
  # inline prefix here is belt-and-suspenders so the carve-out is
297
297
  # self-documenting and robust to sourcing order. No-op when caveman
298
298
  # is absent.
299
- result=$(echo "$full_prompt" | CAVEMAN_DEFAULT_MODE=off claude "${_c2_argv[@]}" -p 2>/dev/null || echo '{"verdict":"REJECT","reasoning":"review execution failed","issues":[]}')
299
+ #
300
+ # STRUCTURED VERDICT (v8.x): when the CLI supports --json-schema,
301
+ # force valid JSON so the sed-carve below never has to rescue
302
+ # prose-wrapped output. --json-schema takes INLINE content, not a
303
+ # path (CLI 2.1.207 rejects a path). On any failure fall through to
304
+ # the plain -p call + sed-carve (unchanged, fail-closed to REJECT).
305
+ # Opt out LOKI_REVIEW_JSON_SCHEMA=off. claude branch only.
306
+ local _c2_schema_dir _c2_schema
307
+ _c2_schema_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
308
+ _c2_schema="${_c2_schema_dir}/loki-ts/data/council-v2-schema.json"
309
+ result=""
310
+ if [ "${LOKI_REVIEW_JSON_SCHEMA:-on}" != "off" ] && [ -f "$_c2_schema" ] \
311
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
312
+ && loki_claude_flag_supported "--json-schema"; then
313
+ local _c2_schema_content _c2_json
314
+ _c2_schema_content="$(cat "$_c2_schema" 2>/dev/null)" || _c2_schema_content=""
315
+ if [ -n "$_c2_schema_content" ]; then
316
+ _c2_json="$(echo "$full_prompt" | CAVEMAN_DEFAULT_MODE=off claude "${_c2_argv[@]}" -p \
317
+ --json-schema "$_c2_schema_content" --output-format json 2>/dev/null)" || _c2_json=""
318
+ if [ -n "$_c2_json" ]; then
319
+ # Pull the schema-validated object out of the envelope.
320
+ result="$(printf '%s' "$_c2_json" | python3 -c 'import sys,json
321
+ try:
322
+ e=json.load(sys.stdin)
323
+ p=e.get("structured_output")
324
+ if not isinstance(p,dict):
325
+ r=e.get("result")
326
+ p=json.loads(r) if isinstance(r,str) else None
327
+ if isinstance(p,dict) and str(p.get("verdict","")).upper() in ("APPROVE","REJECT"):
328
+ sys.stdout.write(json.dumps(p))
329
+ else:
330
+ sys.exit(1)
331
+ except Exception:
332
+ sys.exit(1)' 2>/dev/null)" || result=""
333
+ fi
334
+ fi
335
+ fi
336
+ # Fall through to the plain text call (+ sed-carve) on any miss.
337
+ if [ -z "$result" ]; then
338
+ result=$(echo "$full_prompt" | CAVEMAN_DEFAULT_MODE=off claude "${_c2_argv[@]}" -p 2>/dev/null || echo '{"verdict":"REJECT","reasoning":"review execution failed","issues":[]}')
339
+ fi
300
340
  else
301
341
  result='{"verdict":"REJECT","reasoning":"reviewer CLI unavailable","issues":[]}'
302
342
  fi
@@ -131,20 +131,46 @@ fleet = []
131
131
  runs = []
132
132
  try:
133
133
  from dashboard import registry
134
+ # Self-heal: mark dead-pid "running" zombies as stopped before reading the
135
+ # fleet, so the cockpit does not show 100+ frozen "BUILDING" entries from
136
+ # reaped/crashed sessions. Best-effort; a failure here never blocks the render.
137
+ try:
138
+ registry.reconcile_stale_runs()
139
+ except Exception:
140
+ pass
134
141
  runs = registry.get_fleet_runs(include_inactive=True)
135
142
  except Exception:
136
143
  runs = []
137
144
 
138
145
  for r in runs:
146
+ _running = bool(r.get("running"))
147
+ _status = (r.get("status") or "").lower()
148
+ # A not-running entry must not display an in-flight phase (BUILDING/etc): the
149
+ # reconciled registry status is authoritative for dead runs, so a stopped or
150
+ # stale run shows its status, not a frozen phase left in a stale state file.
151
+ if _running:
152
+ _phase = r.get("phase", "") or ""
153
+ elif _status in ("stale", "stopped", "completed", "failed"):
154
+ _phase = _status
155
+ else:
156
+ _phase = r.get("phase", "") or _status
139
157
  fleet.append({
140
158
  "name": r.get("name") or os.path.basename(r.get("path", "") or "project"),
141
159
  "path": r.get("path", "") or "",
142
- "phase": r.get("phase", "") or "",
160
+ "phase": _phase,
143
161
  "iteration": r.get("iteration", 0) or 0,
144
162
  "status": r.get("status", "") or "",
145
- "running": bool(r.get("running")),
163
+ "running": _running,
146
164
  })
147
165
 
166
+ # Live-first ordering: running builds, then everything else by most-recent.
167
+ # Keeps the useful runs at the top when the fleet has many stopped/stale entries.
168
+ def _fleet_sort_key(e):
169
+ st = (e.get("status") or "").lower()
170
+ rank = 0 if e.get("running") else (1 if st not in ("stale", "stopped") else 2)
171
+ return (rank, -(e.get("iteration") or 0))
172
+ fleet.sort(key=_fleet_sort_key)
173
+
148
174
  # Focused run: --repo wins; else first running fleet run; else cwd.
149
175
  focus_path = focus or cwd
150
176
  focus_entry = None
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """Re-materialize a `claude --json-schema --output-format json` code-review
3
+ envelope into the LEGACY 'VERDICT: X\\nFINDINGS:\\n- [severity] description'
4
+ text, so the downstream text parsers (_classify_verdict, _severity_is_blocking,
5
+ _count_nonblocking_findings in autonomy/run.sh, and the TS mirror) stay
6
+ byte-identical. This is the adapter that lets the code-review verdict path adopt
7
+ structured output WITHOUT rewriting any consumer.
8
+
9
+ Contract:
10
+ - Reads the raw CLI envelope JSON from env _LOKI_CR_JSON (or stdin if unset).
11
+ - Writes legacy text to the path in env _LOKI_CR_OUT (or stdout if unset).
12
+ - FAIL-CLOSED: exits non-zero on ANY structural miss so the bash caller falls
13
+ through to the text path. NEVER emits 'VERDICT: PASS' on a miss.
14
+ - T1 SAFETY (cross-field enforcement JSON Schema cannot express): if ANY finding
15
+ is Critical or High, the verdict is FORCED to FAIL regardless of the model's
16
+ emitted verdict token. A self-contradictory PASS+Critical can never be waved
17
+ through as non-blocking.
18
+
19
+ Live-verified envelope shape (claude 2.1.207): top-level 'structured_output'
20
+ object is the payload; 'result' is the same JSON as a string (fallback);
21
+ 'stop_reason' is 'tool_use' for a valid structured turn.
22
+
23
+ Exit codes (all non-zero == fail-closed, caller falls through to text):
24
+ 0 success 2 no input/output target 3 envelope not JSON / not a dict
25
+ 4 wrong stop_reason 5 no usable payload 6 missing/invalid verdict
26
+ 7 write error
27
+ """
28
+ import json
29
+ import os
30
+ import sys
31
+
32
+ _BLOCKING = {"CRITICAL", "HIGH"}
33
+
34
+
35
+ def rematerialize(raw):
36
+ """Return legacy VERDICT/FINDINGS text, or raise ValueError(exit_code)."""
37
+ try:
38
+ env = json.loads(raw)
39
+ except Exception:
40
+ raise ValueError(3)
41
+ if not isinstance(env, dict):
42
+ raise ValueError(3)
43
+
44
+ # A valid structured turn ends with tool_use. A refusal / max_tokens / other
45
+ # stop_reason means the schema was not honored -> fail-closed.
46
+ if env.get("stop_reason") not in (None, "tool_use"):
47
+ raise ValueError(4)
48
+
49
+ payload = env.get("structured_output")
50
+ if not isinstance(payload, dict):
51
+ res = env.get("result")
52
+ if isinstance(res, str):
53
+ try:
54
+ payload = json.loads(res)
55
+ except Exception:
56
+ payload = None
57
+ if not isinstance(payload, dict):
58
+ raise ValueError(5)
59
+
60
+ verdict = str(payload.get("verdict", "")).strip().upper()
61
+ if verdict not in ("PASS", "FAIL"):
62
+ raise ValueError(6)
63
+
64
+ findings = payload.get("findings")
65
+ if not isinstance(findings, list):
66
+ findings = []
67
+
68
+ body = []
69
+ has_blocking = False
70
+ for f in findings:
71
+ if not isinstance(f, dict):
72
+ continue
73
+ sev = str(f.get("severity", "")).strip()
74
+ desc = str(f.get("description", "")).strip()
75
+ if not sev or not desc:
76
+ continue
77
+ if sev.upper() in _BLOCKING:
78
+ has_blocking = True
79
+ # Bracketed severity form matches _severity_is_blocking /
80
+ # _count_nonblocking_findings regexes exactly.
81
+ body.append("- [" + sev + "] " + desc)
82
+
83
+ # T1: a Critical/High finding forces FAIL even if the model said PASS.
84
+ if has_blocking:
85
+ verdict = "FAIL"
86
+
87
+ lines = ["VERDICT: " + verdict, "FINDINGS:"]
88
+ if body:
89
+ lines.extend(body)
90
+ else:
91
+ lines.append("- None")
92
+ return "\n".join(lines) + "\n"
93
+
94
+
95
+ def main():
96
+ raw = os.environ.get("_LOKI_CR_JSON")
97
+ if raw is None:
98
+ raw = sys.stdin.read()
99
+ out_path = os.environ.get("_LOKI_CR_OUT", "")
100
+ if not raw:
101
+ return 2
102
+
103
+ try:
104
+ text = rematerialize(raw)
105
+ except ValueError as e:
106
+ return int(e.args[0])
107
+
108
+ if out_path:
109
+ try:
110
+ with open(out_path, "w") as fp:
111
+ fp.write(text)
112
+ except OSError:
113
+ return 7
114
+ else:
115
+ sys.stdout.write(text)
116
+ return 0
117
+
118
+
119
+ if __name__ == "__main__":
120
+ sys.exit(main())
@@ -51,6 +51,54 @@
51
51
  _loki_done_recog_invoke() {
52
52
  local prompt="$1"
53
53
  command -v claude >/dev/null 2>&1 || return 1
54
+
55
+ # STRUCTURED VERDICT (v8.x): when the CLI supports --json-schema, force valid
56
+ # JSON so parse_object never has to brace-slice prose. --json-schema takes
57
+ # INLINE content, not a path (CLI 2.1.207 rejects a path). On any miss (flag
58
+ # unsupported, empty, no structured_output) fall through to the plain -p call
59
+ # below; parse_object then handles it and defaults to inconclusive (safe).
60
+ # Opt out LOKI_REVIEW_JSON_SCHEMA=off.
61
+ local _dr_schema_dir _dr_schema
62
+ _dr_schema_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
63
+ _dr_schema="${_dr_schema_dir}/loki-ts/data/done-recognition-schema.json"
64
+ if [ "${LOKI_REVIEW_JSON_SCHEMA:-on}" != "off" ] && [ -f "$_dr_schema" ] \
65
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
66
+ && loki_claude_flag_supported "--json-schema"; then
67
+ local _dr_schema_content _dr_json _dr_obj
68
+ _dr_schema_content="$(cat "$_dr_schema" 2>/dev/null)" || _dr_schema_content=""
69
+ if [ -n "$_dr_schema_content" ]; then
70
+ if command -v timeout >/dev/null 2>&1; then
71
+ _dr_json=$(CAVEMAN_DEFAULT_MODE=off timeout "${LOKI_DONE_RECOG_TIMEOUT}" \
72
+ claude --dangerously-skip-permissions -p "$prompt" \
73
+ --json-schema "$_dr_schema_content" --output-format json 2>/dev/null) || _dr_json=""
74
+ else
75
+ _dr_json=$(CAVEMAN_DEFAULT_MODE=off \
76
+ claude --dangerously-skip-permissions -p "$prompt" \
77
+ --json-schema "$_dr_schema_content" --output-format json 2>/dev/null) || _dr_json=""
78
+ fi
79
+ if [ -n "$_dr_json" ]; then
80
+ _dr_obj=$(printf '%s' "$_dr_json" | python3 -c 'import sys,json
81
+ try:
82
+ e=json.load(sys.stdin)
83
+ p=e.get("structured_output")
84
+ if not isinstance(p,dict):
85
+ r=e.get("result")
86
+ p=json.loads(r) if isinstance(r,str) else None
87
+ if isinstance(p,dict) and "requirements" in p:
88
+ sys.stdout.write(json.dumps(p))
89
+ else:
90
+ sys.exit(1)
91
+ except Exception:
92
+ sys.exit(1)' 2>/dev/null) || _dr_obj=""
93
+ if [ -n "$_dr_obj" ]; then
94
+ printf '%s' "$_dr_obj"
95
+ return 0
96
+ fi
97
+ fi
98
+ fi
99
+ # fall through to the plain text call (parse_object handles it, inconclusive-safe)
100
+ fi
101
+
54
102
  local rc=0
55
103
  local out=""
56
104
  if command -v timeout >/dev/null 2>&1; then
@@ -2,7 +2,7 @@
2
2
  # autonomy/lib/voter-agents.sh -- Phase C (v7.5.20) bash side.
3
3
  #
4
4
  # Builds the JSON declaration of the council voter agents and orchestrates a
5
- # single `claude --agents <json> --json-schema <path>` dispatch per iteration.
5
+ # single `claude --agents <json> --json-schema <inline-content>` dispatch per iteration.
6
6
  # Replaces the ad-hoc per-voter heuristic loop in council_aggregate_votes when
7
7
  # the locally installed Claude CLI supports both flags.
8
8
  #
@@ -176,7 +176,7 @@ loki_finding_schema_path() {
176
176
  # --json-schema in `claude --help`. Either missing -> return 1 (fallback).
177
177
  # 2. Build the agents JSON via loki_voter_agents_json.
178
178
  # 3. Invoke `claude --dangerously-skip-permissions -p <prompt>
179
- # --agents <json> --json-schema <path>` and capture stdout.
179
+ # --agents <json> --json-schema <inline-content>` and capture stdout.
180
180
  # Any non-zero exit or empty stdout -> return 1 (fallback).
181
181
  # 4. Parse the response via python3 (no jq dependency): extract
182
182
  # findings[].{role, vote, reason, confidence}. On parse failure -> 1.
@@ -236,6 +236,16 @@ loki_council_dispatch_agents() {
236
236
  local schema_path
237
237
  schema_path=$(loki_finding_schema_path) || return 1
238
238
 
239
+ # v8.x FIX: `claude --json-schema` takes the schema JSON as INLINE CONTENT,
240
+ # not a file path. Live-verified on CLI 2.1.207: passing a path errors with
241
+ # "--json-schema is not valid JSON: Unrecognized token '/'" -> rc!=0 ->
242
+ # fail-closed to the heuristic path, so this structured dispatch was SILENTLY
243
+ # DEAD (never actually schema-constrained the council). Read the file and pass
244
+ # its content. Fail-closed if unreadable/empty (return 1 -> heuristic path).
245
+ local schema_content
246
+ schema_content=$(cat "$schema_path" 2>/dev/null) || return 1
247
+ [ -n "$schema_content" ] || return 1
248
+
239
249
  # 3. Invoke claude. Guard against absent binary or non-zero exit.
240
250
  command -v claude >/dev/null 2>&1 || return 1
241
251
 
@@ -274,7 +284,7 @@ loki_council_dispatch_agents() {
274
284
  env CAVEMAN_DEFAULT_MODE=off claude --dangerously-skip-permissions \
275
285
  -p "$prompt" \
276
286
  --agents "$agents_json" \
277
- --json-schema "$schema_path" 2>"$stderr_log") || rc=$?
287
+ --json-schema "$schema_content" 2>"$stderr_log") || rc=$?
278
288
  if [ "$rc" -ne 0 ] || [ -z "$response" ]; then
279
289
  return 1
280
290
  fi
package/autonomy/loki CHANGED
@@ -826,7 +826,7 @@ show_help() {
826
826
  echo "New here? Try these in order:"
827
827
  echo " loki doctor Check your setup is ready (a few seconds)"
828
828
  echo " loki quickstart Guided first build from your idea (no PRD needed)"
829
- echo " loki start ./prd.md Build from a spec (PRD file, GitHub issue, or no arg)"
829
+ echo " loki start ./prd.md Build from a spec (PRD file, GitHub/GitLab/Jira/Azure issue, or no arg)"
830
830
  echo " quickstart = guided first build; quick = one small task (3 iters max)."
831
831
  echo " Docs: https://github.com/asklokesh/loki-mode | Report a problem: loki crash"
832
832
  echo ""
@@ -846,7 +846,7 @@ show_help() {
846
846
  echo "Commands:"
847
847
  echo ""
848
848
  echo "Build:"
849
- echo " start [SPEC] Start a build (PRD file, GitHub issue, or no arg)"
849
+ echo " start [SPEC] Start a build (PRD file, GitHub/GitLab/Jira/Azure issue, or no arg)"
850
850
  echo " plan <PRD-file> Dry-run analysis: complexity, cost, execution plan"
851
851
  echo " quickstart [idea] Guided first build: setup, idea, template, plan, go"
852
852
  echo ""
@@ -1043,7 +1043,7 @@ show_landing() {
1043
1043
  echo -e " ${CYAN}loki tour${NC} See a real result now (no provider, no key, no spend)"
1044
1044
  echo -e " ${CYAN}loki doctor${NC} Check your setup is ready (a few seconds)"
1045
1045
  echo -e " ${CYAN}loki quickstart${NC} Guided first build from your idea (no PRD needed)"
1046
- echo -e " ${CYAN}loki start ./prd.md${NC} Build from a spec (PRD file, GitHub issue, or no arg)"
1046
+ echo -e " ${CYAN}loki start ./prd.md${NC} Build from a spec (PRD file, GitHub/GitLab/Jira/Azure issue, or no arg)"
1047
1047
  echo " quickstart = guided first build; quick = one small task (3 iters max)."
1048
1048
  echo ""
1049
1049
  echo -e "Need help? ${CYAN}loki help${NC} lists every command."
@@ -8821,7 +8821,13 @@ if council:
8821
8821
  fleet = s.get('fleet') or []
8822
8822
  if fleet:
8823
8823
  cap = 8 # cap so a huge registry (e.g. 171 runs) never floods the summary
8824
- print(f" Fleet ({len(fleet)}):")
8824
+ active = sum(1 for r in fleet if r.get("running"))
8825
+ # Header shows active vs total so a long tail of finished/stale runs reads
8826
+ # honestly (e.g. "0 active / 136 total") instead of an alarming bare count.
8827
+ if active:
8828
+ print(f" Fleet ({active} active / {len(fleet)} total):")
8829
+ else:
8830
+ print(f" Fleet ({len(fleet)} total, none active):")
8825
8831
  for r in fleet[:cap]:
8826
8832
  mark = "*" if r.get("running") else " "
8827
8833
  print(f" {mark} {r.get('name','?'):<28} iter {r.get('iteration',0)} {r.get('phase') or r.get('status') or ''}")
package/autonomy/run.sh CHANGED
@@ -5911,8 +5911,15 @@ compute_codebase_signature() {
5911
5911
  # truncating the output and misaligning the path<->hash pairing. Drop
5912
5912
  # them; their removal from the list is itself the detected change.
5913
5913
  gitc_deleted=$(git ls-files --deleted -z 2>/dev/null | tr '\0' '\n')
5914
+ # Exclude .loki/ AND Loki's own session-end artifacts (HANDOFF.md,
5915
+ # USAGE.md) written+committed to the repo ROOT by the completion path
5916
+ # (LOKI_HANDOFF / commit_session_changes, both default-on). Those are
5917
+ # runtime OUTPUT of the session, not user codebase; counting them would
5918
+ # make the next PRD-reuse check see a spurious "codebase changed" and
5919
+ # flip reuse->update (the signature is persisted per-iteration BEFORE
5920
+ # the after-loop HANDOFF write, so it can never account for them).
5914
5921
  gitc_paths=$( { git ls-files -z 2>/dev/null; git ls-files --others --exclude-standard -z 2>/dev/null; } \
5915
- | tr '\0' '\n' | grep -vE '(^|/)\.loki(/|$)' | LC_ALL=C sort -u )
5922
+ | tr '\0' '\n' | grep -vE '(^|/)\.loki(/|$)|^HANDOFF\.md$|^USAGE\.md$' | LC_ALL=C sort -u )
5916
5923
  if [ -n "$gitc_deleted" ]; then
5917
5924
  gitc_paths=$(printf '%s\n' "$gitc_paths" | grep -vxF -f <(printf '%s\n' "$gitc_deleted") || true)
5918
5925
  fi
@@ -5933,6 +5940,7 @@ compute_codebase_signature() {
5933
5940
  -o -name build -o -name .next -o -name target -o -name vendor \
5934
5941
  -o -name __pycache__ -o -name .venv -o -name venv \) -prune -o \
5935
5942
  -type f -print 2>/dev/null \
5943
+ | grep -vE '^\./(HANDOFF|USAGE)\.md$' \
5936
5944
  | while IFS= read -r f; do
5937
5945
  local sz
5938
5946
  sz=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0)
@@ -9658,8 +9666,16 @@ sys.stdout.write(t.strip())
9658
9666
  local output
9659
9667
  # Pass matched files explicitly (node --test globbing is
9660
9668
  # Node-version-sensitive); quote each so paths with spaces survive.
9669
+ # Force --test-reporter=tap: Node 20+ made the human "spec" reporter
9670
+ # the default for a TTY and Node 26 emits it even under capture
9671
+ # ("i pass N" / check+cross marks) instead of the TAP "# pass N" /
9672
+ # "ok N - " lines that BOTH the count parser below AND
9673
+ # _loki_zero_tests_executed rely on. TAP has been stable since Node 18,
9674
+ # so forcing it restores a deterministic, version-independent format
9675
+ # without touching any parser. Older node ignores nothing here (tap is
9676
+ # a valid built-in reporter on every supported version).
9661
9677
  output=$(cd "${TARGET_DIR:-.}" && timeout "${LOKI_GATE_TIMEOUT:-300}" \
9662
- node --test "${_nt_files[@]}" 2>&1) || test_passed=false
9678
+ node --test --test-reporter=tap "${_nt_files[@]}" 2>&1) || test_passed=false
9663
9679
  # tail -14 so node's TAP summary block (# tests / # pass N / # fail N,
9664
9680
  # ~10 lines) survives truncation for the best-effort count parse below.
9665
9681
  details="node --test: $(echo "$output" | tail -14 | tr '\n' ' ')"
@@ -10994,6 +11010,39 @@ _dispatch_reviewer() {
10994
11010
  # then deletes its flag and emits nothing). Set inline, not via
10995
11011
  # the helper, so the carve-out holds even when the helper is
10996
11012
  # out of scope. No-op when caveman is absent.
11013
+ # STRUCTURED VERDICT (v8.x): when the CLI supports --json-schema,
11014
+ # force valid JSON and re-materialize the LEGACY VERDICT/FINDINGS text
11015
+ # so every downstream consumer (_classify_verdict,
11016
+ # _severity_is_blocking, _count_nonblocking_findings, mergeability,
11017
+ # DA arm, aggregate.json) stays byte-identical. Fail-closed: any
11018
+ # failure falls through to the text path below (which itself may leave
11019
+ # an empty file -> NO_VERDICT -> inconclusive block). NEVER a PASS on a
11020
+ # miss. --json-schema takes INLINE content, not a path (CLI 2.1.207
11021
+ # rejects a path). Opt out with LOKI_REVIEW_JSON_SCHEMA=off.
11022
+ local _cr_root _cr_here _cr_schema _cr_remat
11023
+ _cr_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
11024
+ _cr_here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11025
+ _cr_schema="${_cr_root}/loki-ts/data/code-review-schema.json"
11026
+ _cr_remat="${_cr_here}/lib/cr-rematerialize.py"
11027
+ if [ "${LOKI_REVIEW_JSON_SCHEMA:-on}" != "off" ] \
11028
+ && [ -f "$_cr_schema" ] && [ -f "$_cr_remat" ] \
11029
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
11030
+ && loki_claude_flag_supported "--json-schema"; then
11031
+ local _cr_schema_content _cr_json _cr_rc=0
11032
+ _cr_schema_content="$(cat "$_cr_schema" 2>/dev/null)" || _cr_schema_content=""
11033
+ if [ -n "$_cr_schema_content" ]; then
11034
+ _cr_json="$(CAVEMAN_DEFAULT_MODE=off \
11035
+ claude "${_rv_argv[@]}" -p "$prompt_text" \
11036
+ --json-schema "$_cr_schema_content" \
11037
+ --output-format json 2>/dev/null)" || _cr_rc=$?
11038
+ if [ "$_cr_rc" -eq 0 ] && [ -n "$_cr_json" ] \
11039
+ && _LOKI_CR_JSON="$_cr_json" _LOKI_CR_OUT="$review_output" \
11040
+ python3 "$_cr_remat"; then
11041
+ return 0
11042
+ fi
11043
+ fi
11044
+ # fall through to the text path below (fail-closed)
11045
+ fi
10997
11046
  CAVEMAN_DEFAULT_MODE=off \
10998
11047
  claude "${_rv_argv[@]}" -p "$prompt_text" \
10999
11048
  --output-format text > "$review_output" 2>/dev/null
@@ -427,6 +427,14 @@ _verify_zero_tests_executed() {
427
427
  verify_gate_tests() {
428
428
  local tree="$1"
429
429
  local timeout_s="${LOKI_GATE_TIMEOUT:-300}"
430
+ # Bounded-exec prefix: "timeout <secs>" / "gtimeout <secs>", or EMPTY when
431
+ # neither is on PATH (bare macOS, or a shadowed PATH). A bare `timeout ...`
432
+ # would exit 127 (command not found) and be misread as a test FAILURE ->
433
+ # spurious BLOCKED verdict. Degrade to running without a wall-clock cap
434
+ # instead. Mirrors run.sh's with_timeout discipline.
435
+ local _vt_bin _vt
436
+ _vt_bin="$(_verify_runtime_timeout_bin)"
437
+ if [ -n "$_vt_bin" ]; then _vt="$_vt_bin $timeout_s"; else _vt=""; fi
430
438
  local runner="none"
431
439
  local rc=0
432
440
  local out=""
@@ -434,13 +442,13 @@ verify_gate_tests() {
434
442
  if [ -f "$tree/package.json" ]; then
435
443
  if grep -q '"vitest"' "$tree/package.json" 2>/dev/null; then
436
444
  runner="vitest"
437
- out="$(cd "$tree" && timeout "$timeout_s" npx vitest run 2>&1)" || rc=$?
445
+ out="$(cd "$tree" && $_vt npx vitest run 2>&1)" || rc=$?
438
446
  elif grep -q '"jest"' "$tree/package.json" 2>/dev/null; then
439
447
  runner="jest"
440
- out="$(cd "$tree" && timeout "$timeout_s" npx jest --passWithNoTests --forceExit 2>&1)" || rc=$?
448
+ out="$(cd "$tree" && $_vt npx jest --passWithNoTests --forceExit 2>&1)" || rc=$?
441
449
  elif grep -q '"mocha"' "$tree/package.json" 2>/dev/null; then
442
450
  runner="mocha"
443
- out="$(cd "$tree" && timeout "$timeout_s" npx mocha 2>&1)" || rc=$?
451
+ out="$(cd "$tree" && $_vt npx mocha 2>&1)" || rc=$?
444
452
  fi
445
453
  fi
446
454
 
@@ -480,7 +488,7 @@ verify_gate_tests() {
480
488
  if [ "$has_python" = "true" ]; then
481
489
  if command -v pytest >/dev/null 2>&1; then
482
490
  runner="pytest"
483
- out="$(cd "$tree" && timeout "$timeout_s" pytest --tb=short 2>&1)" || rc=$?
491
+ out="$(cd "$tree" && $_vt pytest --tb=short 2>&1)" || rc=$?
484
492
  elif command -v python3 >/dev/null 2>&1; then
485
493
  # #139 parity: pytest ABSENT but a Python suite exists -> run it with
486
494
  # stdlib unittest (zero third-party deps) instead of going blindly
@@ -490,7 +498,7 @@ verify_gate_tests() {
490
498
  # a "Ran 0 tests" line detected by the zero-test guard on the run.sh
491
499
  # mirror) so this never green-washes an empty suite.
492
500
  runner="unittest"
493
- out="$(cd "$tree" && timeout "$timeout_s" python3 -m unittest discover -p 'test_*.py' 2>&1)" || rc=$?
501
+ out="$(cd "$tree" && $_vt python3 -m unittest discover -p 'test_*.py' 2>&1)" || rc=$?
494
502
  else
495
503
  # Applicable but cannot run -> inconclusive (Entanglement 2).
496
504
  _verify_add_gate "tests" "inconclusive" "pytest" "python project detected but no pytest/python3 on PATH" "true"
@@ -502,7 +510,7 @@ verify_gate_tests() {
502
510
  if [ "$runner" = "none" ] && [ -f "$tree/go.mod" ]; then
503
511
  if command -v go >/dev/null 2>&1; then
504
512
  runner="go-test"
505
- out="$(cd "$tree" && timeout "$timeout_s" go test ./... 2>&1)" || rc=$?
513
+ out="$(cd "$tree" && $_vt go test ./... 2>&1)" || rc=$?
506
514
  else
507
515
  _verify_add_gate "tests" "inconclusive" "go-test" "go project detected but go not on PATH" "true"
508
516
  return 0
@@ -512,7 +520,7 @@ verify_gate_tests() {
512
520
  if [ "$runner" = "none" ] && [ -f "$tree/Cargo.toml" ]; then
513
521
  if command -v cargo >/dev/null 2>&1; then
514
522
  runner="cargo-test"
515
- out="$(cd "$tree" && timeout "$timeout_s" cargo test 2>&1)" || rc=$?
523
+ out="$(cd "$tree" && $_vt cargo test 2>&1)" || rc=$?
516
524
  else
517
525
  _verify_add_gate "tests" "inconclusive" "cargo-test" "rust project detected but cargo not on PATH" "true"
518
526
  return 0
@@ -550,7 +558,13 @@ verify_gate_tests() {
550
558
  runner="node-test"
551
559
  # Pass matched files explicitly (node --test globbing is
552
560
  # Node-version-sensitive); quote each so paths with spaces survive.
553
- out="$(cd "$tree" && timeout "$timeout_s" node --test "${_nt_files[@]}" 2>&1)" || rc=$?
561
+ # Force --test-reporter=tap: Node 20+ defaults to the human "spec"
562
+ # reporter (Node 26 emits it even under capture), whose "i pass N" /
563
+ # check-mark lines the count parser + zero-tests detector below do NOT
564
+ # match; they expect TAP "# pass N" / "ok N - ". TAP is stable since
565
+ # Node 18, so forcing it restores a version-independent format with no
566
+ # parser change. Parity with autonomy/run.sh:9662.
567
+ out="$(cd "$tree" && $_vt node --test --test-reporter=tap "${_nt_files[@]}" 2>&1)" || rc=$?
554
568
  fi
555
569
  fi
556
570
 
@@ -564,6 +578,22 @@ verify_gate_tests() {
564
578
  return 0
565
579
  fi
566
580
 
581
+ # #82 (Python 3.12+): `python3 -m unittest` exits 5 (NOT 0) when it discovers
582
+ # ZERO tests -- e.g. a suite of pytest-style bare `def test_*` functions that
583
+ # unittest (TestCase-only) cannot collect. That non-zero exit would otherwise
584
+ # fall into the fail branch below and record tests=fail -> BLOCKED, when the
585
+ # honest classification is "applicable but ran no real tests" = inconclusive
586
+ # -> CONCERNS. Route the unittest zero-discovery case through the same guard
587
+ # the rc==0 path uses (its unittest arm matches "Ran 0 tests"/"NO TESTS RAN"
588
+ # but was unreachable behind rc==0). Scoped to unittest so pytest/go/cargo/node
589
+ # non-zero exits still correctly record fail.
590
+ if [ "$rc" -eq 5 ] && [ "$runner" = "unittest" ] \
591
+ && _verify_zero_tests_executed "$runner" "$out" "${_nt_files[@]:-}"; then
592
+ _verify_add_gate "tests" "inconclusive" "$runner" \
593
+ "runner ran but discovered zero tests (source_without_runnable_tests)" "true"
594
+ return 0
595
+ fi
596
+
567
597
  if [ "$rc" -eq 0 ]; then
568
598
  # #82: a green run that executed ZERO real tests is a mini fake-green.
569
599
  # Record it as HONEST inconclusive (forces at-least-CONCERNS), NOT pass
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.128.1"
10
+ __version__ = "7.129.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -396,6 +396,41 @@ def prune_missing_projects(running_ids: Optional[set] = None) -> list[dict]:
396
396
  return pruned
397
397
 
398
398
 
399
+ def reconcile_stale_runs() -> int:
400
+ """Self-heal the registry: mark dead-pid in-flight entries as stopped.
401
+
402
+ `loki start` records status="running" for a build, and an exit trap marks it
403
+ stopped on clean shutdown. But a reaped session, a crash, or a SIGKILL skips
404
+ that trap, leaving the entry frozen at "running"/"building" forever with a
405
+ dead pid. Over time these zombies accumulate (a fleet of 130+ "BUILDING"
406
+ entries with no live process), polluting the cockpit and dashboard.
407
+
408
+ This reconciles reality: for every entry whose recorded pid is NOT alive but
409
+ whose status is still in-flight (running/building/active/in_progress), set
410
+ status="stopped". Non-destructive (the entry is kept, just corrected) and
411
+ never touches a live-pid entry or an already-terminal status. Best-effort and
412
+ concurrency-safe (runs under the registry lock with the atomic save).
413
+
414
+ Returns:
415
+ The number of entries reconciled (0 when the registry is already clean).
416
+ """
417
+ IN_FLIGHT = {"running", "building", "active", "in_progress"}
418
+ reconciled = 0
419
+ with _registry_lock():
420
+ registry = _load_registry()
421
+ projects = registry.get("projects", {})
422
+ changed = False
423
+ for project in projects.values():
424
+ status = str(project.get("status") or "").lower()
425
+ if status in IN_FLIGHT and not _pid_alive(project.get("pid")):
426
+ project["status"] = "stopped"
427
+ reconciled += 1
428
+ changed = True
429
+ if changed:
430
+ _save_registry(registry)
431
+ return reconciled
432
+
433
+
399
434
  def check_project_health(identifier: str) -> dict:
400
435
  """
401
436
  Check the health status of a project.
@@ -754,11 +789,17 @@ def get_fleet_runs(include_inactive: bool = True) -> list[dict]:
754
789
  running = _pid_alive(pid)
755
790
  snap = _read_project_run_snapshot(path)
756
791
 
757
- # A live pid is authoritative for "running"; otherwise reflect the
758
- # registry status (stopped/active/missing). This mirrors the
759
- # running-projects endpoint's pid-first precedence.
792
+ # A live pid is authoritative for "running". When the pid is DEAD but the
793
+ # registry still says the run is in-flight (running/building/active), the
794
+ # run was reaped or crashed without its exit trap marking it stopped -- it
795
+ # is a stale zombie, NOT still building. Report it honestly as "stale" so
796
+ # the fleet does not show 100+ frozen "BUILDING" entries. A dead pid with
797
+ # a terminal registry status (stopped/completed/failed) keeps that status.
798
+ reg_status = (p.get("status") or "unknown").lower()
760
799
  if running:
761
800
  status = "running"
801
+ elif reg_status in ("running", "building", "active", "in_progress"):
802
+ status = "stale"
762
803
  else:
763
804
  status = p.get("status") or "unknown"
764
805