loki-mode 7.19.0 → 7.19.2

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-to-product system. Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product via the RARV-C closure loop, with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.19.0
6
+ # Loki Mode v7.19.2
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -383,4 +383,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
383
383
 
384
384
  ---
385
385
 
386
- **v7.19.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
386
+ **v7.19.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.19.0
1
+ 7.19.2
@@ -28,6 +28,10 @@
28
28
  # LOKI_COUNCIL_CONVERGENCE_WINDOW - Iterations to track for convergence (default: 3)
29
29
  # LOKI_COUNCIL_STAGNATION_LIMIT - Max iterations with no git changes (default: 5)
30
30
  # LOKI_COUNCIL_DONE_SIGNAL_LIMIT - Max total done signals before force stop (default: 10)
31
+ # LOKI_UNCERTAINTY_ESCALATION - Proactive stuck-escalation decision (default: 1; set 0 to disable, byte-identical)
32
+ # LOKI_UNCERTAINTY_ROUNDS - Consecutive co-occurrence rounds before escalate (default: 2)
33
+ # LOKI_UNCERTAINTY_NOCHANGE_MIN - Proxy 1 threshold on consecutive_no_change (default: COUNCIL_STAGNATION_LIMIT - 1)
34
+ # LOKI_UNCERTAINTY_SPLIT_ROUNDS - Proxy 3 trailing split-round run length (default: 2)
31
35
  #
32
36
  # Usage:
33
37
  # source autonomy/completion-council.sh
@@ -221,6 +225,7 @@ council_track_iteration() {
221
225
  _COUNCIL_TOTAL_DONE_SIGNALS="$COUNCIL_TOTAL_DONE_SIGNALS" \
222
226
  _COUNCIL_ITERATION="${ITERATION_COUNT:-0}" \
223
227
  _COUNCIL_FILES_CHANGED="$files_changed" \
228
+ _COUNCIL_DIFF_HASH="$combined_hash" \
224
229
  python3 -c "
225
230
  import json, os
226
231
  state_file = os.environ['_COUNCIL_STATE_FILE']
@@ -234,6 +239,7 @@ state['done_signals'] = int(os.environ['_COUNCIL_DONE_SIGNALS'])
234
239
  state['total_done_signals'] = int(os.environ['_COUNCIL_TOTAL_DONE_SIGNALS'])
235
240
  state['last_track_iteration'] = int(os.environ['_COUNCIL_ITERATION'])
236
241
  state['files_changed'] = int(os.environ['_COUNCIL_FILES_CHANGED'])
242
+ state['last_diff_hash'] = os.environ['_COUNCIL_DIFF_HASH']
237
243
  with open(state_file, 'w') as f:
238
244
  json.dump(state, f, indent=2)
239
245
  " || log_warn "Failed to update council tracking state"
@@ -263,6 +269,282 @@ council_circuit_breaker_triggered() {
263
269
  return 1
264
270
  }
265
271
 
272
+ #===============================================================================
273
+ # Uncertainty-Gated Escalation - pure stuck-detection DECISION function
274
+ #
275
+ # Returns 0 = escalate now, 1 = do not escalate. Reads ONLY persisted state
276
+ # (the council state.json for the three proxies, plus its own uncertainty.json
277
+ # for the ring buffer + co-occurrence streak + debounce flag). Mutates only its
278
+ # own uncertainty.json (atomic temp+mv). Fires NO notifications and touches NO
279
+ # PAUSE file: the run.sh action site interprets the return code and performs the
280
+ # side effects. This keeps the function sourceable and testable in isolation.
281
+ #
282
+ # Three proxies (all read from state, no live shell vars, no git calls):
283
+ # P1 (no-change) : state.json consecutive_no_change >= NOCHANGE_MIN
284
+ # (default COUNCIL_STAGNATION_LIMIT - 1, i.e. approaching
285
+ # the circuit-breaker limit).
286
+ # P2 (oscillation) : current state.json last_diff_hash recurs at distance >= 2
287
+ # in the ring buffer (A -> B -> A). Immediate repeat (A -> A)
288
+ # is P1's territory and is excluded.
289
+ # P3 (council split): the trailing SPLIT_ROUNDS entries of state.json verdicts
290
+ # are all result == "REJECTED" with approve >= 1.
291
+ # Escalate iff >= 2 proxies are hot AND that has held for ROUNDS consecutive
292
+ # rounds AND we have not already escalated this episode (debounce). Re-arm when
293
+ # co-occurrence drops below 2 in any later round.
294
+ #===============================================================================
295
+
296
+ # Resolve the uncertainty.json path co-located with the council state root so a
297
+ # sourced test (which sets COUNCIL_STATE_DIR to a throwaway dir) reads and writes
298
+ # in that same throwaway dir, never the developer's real cwd. In production
299
+ # COUNCIL_STATE_DIR is "${TARGET_DIR}/.loki/council", so its parent is the right
300
+ # ".loki" and this lands at ".loki/state/uncertainty.json".
301
+ _uncertainty_state_path() {
302
+ local base_dir="${COUNCIL_STATE_DIR:-${TARGET_DIR:-.}/.loki/council}"
303
+ local loki_root
304
+ loki_root="$(dirname "$base_dir")"
305
+ echo "$loki_root/state/uncertainty.json"
306
+ }
307
+
308
+ # Read uncertainty.json (or emit a default object if missing/corrupt) to stdout.
309
+ _uncertainty_read_state() {
310
+ local file="$1"
311
+ _UNC_FILE="$file" python3 -c "
312
+ import json, os
313
+ f = os.environ['_UNC_FILE']
314
+ default = {
315
+ 'schema_version': '1.0.0',
316
+ 'consecutive_co_occur': 0,
317
+ 'escalated_episode': False,
318
+ 'escalated_at_iteration': 0,
319
+ 'diff_hash_ring': [],
320
+ 'last_round_iteration': -1,
321
+ 'last_proxies': {'p1': False, 'p2': False, 'p3': False},
322
+ }
323
+ try:
324
+ with open(f) as fh:
325
+ state = json.load(fh)
326
+ if not isinstance(state, dict):
327
+ state = {}
328
+ except (json.JSONDecodeError, FileNotFoundError, OSError):
329
+ state = {}
330
+ for k, v in default.items():
331
+ state.setdefault(k, v)
332
+ print(json.dumps(state))
333
+ " 2>/dev/null || echo '{}'
334
+ }
335
+
336
+ # Write a JSON string (read from _UNC_PAYLOAD) to uncertainty.json atomically
337
+ # (temp + mv), mirroring evidence-block.json.
338
+ _uncertainty_write_state() {
339
+ local file="$1"
340
+ local payload="$2"
341
+ local dir tmp
342
+ dir="$(dirname "$file")"
343
+ mkdir -p "$dir" 2>/dev/null || true
344
+ tmp="${file}.tmp.$$"
345
+ if _UNC_PAYLOAD="$payload" _UNC_TMP="$tmp" python3 -c "
346
+ import json, os
347
+ payload = os.environ['_UNC_PAYLOAD']
348
+ tmp = os.environ['_UNC_TMP']
349
+ state = json.loads(payload)
350
+ with open(tmp, 'w') as fh:
351
+ json.dump(state, fh, indent=2)
352
+ " 2>/dev/null; then
353
+ mv "$tmp" "$file" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
354
+ return 0
355
+ fi
356
+ rm -f "$tmp" 2>/dev/null || true
357
+ return 1
358
+ }
359
+
360
+ uncertainty_should_escalate() {
361
+ # Knob first: opt-out is byte-identical to prior behavior. No read, no write,
362
+ # no state-file creation when disabled.
363
+ [ "${LOKI_UNCERTAINTY_ESCALATION:-1}" = "0" ] && return 1
364
+
365
+ # Tunable knobs (read inline; defaults documented in the env-var block).
366
+ local rounds_needed="${LOKI_UNCERTAINTY_ROUNDS:-2}"
367
+ local split_rounds="${LOKI_UNCERTAINTY_SPLIT_ROUNDS:-2}"
368
+ local nochange_min="${LOKI_UNCERTAINTY_NOCHANGE_MIN:-}"
369
+ if [ -z "$nochange_min" ]; then
370
+ nochange_min=$(( ${COUNCIL_STAGNATION_LIMIT:-5} - 1 ))
371
+ [ "$nochange_min" -lt 1 ] && nochange_min=1
372
+ fi
373
+ # Bounded constants.
374
+ local ring_size=6
375
+
376
+ # Resolve state file locations (council state root co-located).
377
+ local council_dir="${COUNCIL_STATE_DIR:-${TARGET_DIR:-.}/.loki/council}"
378
+ local state_json="$council_dir/state.json"
379
+ local unc_file
380
+ unc_file="$(_uncertainty_state_path)"
381
+ local iteration="${ITERATION_COUNT:-0}"
382
+
383
+ # Load prior uncertainty state.
384
+ local prior
385
+ prior="$(_uncertainty_read_state "$unc_file")"
386
+
387
+ # Compute the new state and decision entirely in python from persisted inputs.
388
+ # Echoes one line: "<rc> <new_json>" where rc is 0 (escalate) or 1 (no).
389
+ local result
390
+ result=$(_UNC_PRIOR="$prior" \
391
+ _UNC_STATE_JSON="$state_json" \
392
+ _UNC_ITERATION="$iteration" \
393
+ _UNC_ROUNDS="$rounds_needed" \
394
+ _UNC_SPLIT_ROUNDS="$split_rounds" \
395
+ _UNC_NOCHANGE_MIN="$nochange_min" \
396
+ _UNC_RING_SIZE="$ring_size" \
397
+ python3 -c "
398
+ import json, os
399
+
400
+ prior = json.loads(os.environ['_UNC_PRIOR'])
401
+ iteration = int(os.environ['_UNC_ITERATION'])
402
+ rounds_needed = int(os.environ['_UNC_ROUNDS'])
403
+ split_rounds = int(os.environ['_UNC_SPLIT_ROUNDS'])
404
+ nochange_min = int(os.environ['_UNC_NOCHANGE_MIN'])
405
+ ring_size = int(os.environ['_UNC_RING_SIZE'])
406
+
407
+ # Load council state.json (proxies). Missing/corrupt -> proxies cold.
408
+ try:
409
+ with open(os.environ['_UNC_STATE_JSON']) as fh:
410
+ cstate = json.load(fh)
411
+ if not isinstance(cstate, dict):
412
+ cstate = {}
413
+ except (json.JSONDecodeError, FileNotFoundError, OSError):
414
+ cstate = {}
415
+
416
+ ring = prior.get('diff_hash_ring', [])
417
+ if not isinstance(ring, list):
418
+ ring = []
419
+ last_round = prior.get('last_round_iteration', -1)
420
+ try:
421
+ last_round = int(last_round)
422
+ except (TypeError, ValueError):
423
+ last_round = -1
424
+
425
+ # Idempotency: a repeated call at the same iteration must not double-mutate.
426
+ # Recompute proxies and re-emit the prior decision without pushing the ring or
427
+ # advancing the streak again.
428
+ same_round = (iteration == last_round)
429
+
430
+ # --- Proxy 1: no-change approaching circuit breaker ---
431
+ try:
432
+ no_change = int(cstate.get('consecutive_no_change', 0))
433
+ except (TypeError, ValueError):
434
+ no_change = 0
435
+ p1 = no_change >= nochange_min
436
+
437
+ # --- Proxy 2: diff-hash recurrence at distance >= 2 (genuine oscillation) ---
438
+ cur_hash = cstate.get('last_diff_hash', '')
439
+ p2 = False
440
+ if cur_hash:
441
+ # Genuine oscillation (A -> B -> A) requires TWO things:
442
+ # 1. cur_hash recurs in the ring excluding the most-recent entry
443
+ # (distance >= 2; distance 1 immediate-repeat is P1's territory), AND
444
+ # 2. the most-recent ring entry (the previous round's hash) is DIFFERENT
445
+ # from cur_hash, i.e. there is an intervening distinct hash.
446
+ # Without (2), pure stagnation (A, A, A, ...) fills the ring with the same
447
+ # hash and would falsely fire P2 from the SAME root condition as P1, letting
448
+ # a single condition (no-change) light two proxies and escalate alone. That
449
+ # contradicts the 2-of-3 independent-proxy safety guarantee. Requiring an
450
+ # intervening distinct hash keeps A,B,A hot and A,A,A cold.
451
+ prev_hash = ring[-1] if ring else ''
452
+ if prev_hash != cur_hash:
453
+ for h in ring[:-1]:
454
+ if h == cur_hash:
455
+ p2 = True
456
+ break
457
+
458
+ # --- Proxy 3: persistent council split (trailing REJECTED with approve>=1) ---
459
+ verdicts = cstate.get('verdicts', [])
460
+ if not isinstance(verdicts, list):
461
+ verdicts = []
462
+ split_run = 0
463
+ for v in reversed(verdicts):
464
+ if not isinstance(v, dict):
465
+ break
466
+ try:
467
+ approve = int(v.get('approve', 0))
468
+ except (TypeError, ValueError):
469
+ approve = 0
470
+ if v.get('result') == 'REJECTED' and approve >= 1:
471
+ split_run += 1
472
+ else:
473
+ break
474
+ p3 = split_run >= split_rounds
475
+
476
+ hot_count = (1 if p1 else 0) + (1 if p2 else 0) + (1 if p3 else 0)
477
+ co_occur = hot_count >= 2
478
+
479
+ streak = prior.get('consecutive_co_occur', 0)
480
+ try:
481
+ streak = int(streak)
482
+ except (TypeError, ValueError):
483
+ streak = 0
484
+ escalated_episode = bool(prior.get('escalated_episode', False))
485
+ escalated_at = prior.get('escalated_at_iteration', 0)
486
+ try:
487
+ escalated_at = int(escalated_at)
488
+ except (TypeError, ValueError):
489
+ escalated_at = 0
490
+
491
+ new_state = dict(prior)
492
+ new_state['schema_version'] = prior.get('schema_version', '1.0.0')
493
+ new_state['last_proxies'] = {'p1': p1, 'p2': p2, 'p3': p3}
494
+
495
+ if same_round:
496
+ # No mutation of ring/streak; report no-escalate on the repeat call so we
497
+ # never fire twice for one round. Proxy snapshot is refreshed (harmless).
498
+ new_state['diff_hash_ring'] = ring
499
+ new_state['consecutive_co_occur'] = streak
500
+ new_state['escalated_episode'] = escalated_episode
501
+ new_state['escalated_at_iteration'] = escalated_at
502
+ new_state['last_round_iteration'] = last_round
503
+ rc = 1
504
+ else:
505
+ # Advance the ring with this round's hash (bounded).
506
+ if cur_hash:
507
+ ring = ring + [cur_hash]
508
+ if len(ring) > ring_size:
509
+ ring = ring[-ring_size:]
510
+
511
+ if co_occur:
512
+ streak += 1
513
+ else:
514
+ # Re-arm on clear: a resolved episode may legitimately re-escalate later.
515
+ streak = 0
516
+ escalated_episode = False
517
+
518
+ rc = 1
519
+ if co_occur and streak >= rounds_needed and not escalated_episode:
520
+ rc = 0
521
+ escalated_episode = True
522
+ escalated_at = iteration
523
+
524
+ new_state['diff_hash_ring'] = ring
525
+ new_state['consecutive_co_occur'] = streak
526
+ new_state['escalated_episode'] = escalated_episode
527
+ new_state['escalated_at_iteration'] = escalated_at
528
+ new_state['last_round_iteration'] = iteration
529
+
530
+ print(str(rc) + ' ' + json.dumps(new_state))
531
+ " 2>/dev/null) || return 1
532
+
533
+ [ -z "$result" ] && return 1
534
+
535
+ local rc new_json
536
+ rc="${result%% *}"
537
+ new_json="${result#* }"
538
+
539
+ # Persist the new state atomically (failure to persist must not escalate).
540
+ _uncertainty_write_state "$unc_file" "$new_json" || return 1
541
+
542
+ case "$rc" in
543
+ 0) return 0 ;;
544
+ *) return 1 ;;
545
+ esac
546
+ }
547
+
266
548
  #===============================================================================
267
549
  # Council Voting - 3 independent reviewers check completion
268
550
  #===============================================================================
@@ -893,6 +1175,200 @@ GATE_EOF
893
1175
  return 0
894
1176
  }
895
1177
 
1178
+ #===============================================================================
1179
+ # Council Evidence Hard Gate (v7.19.1) - "verified completion"
1180
+ #===============================================================================
1181
+ # Block the completion-approval path unless there is real on-disk evidence that
1182
+ # the run actually shipped: a nonzero git diff vs the run-start SHA AND a green
1183
+ # test signal (where a test suite exists). Cloned from council_checklist_gate:
1184
+ # return 0 = pass (OK to complete), return 1 = block (treated as CONTINUE).
1185
+ # Blocks ONLY on positive fabrication evidence (empty diff, or a runner that
1186
+ # actually ran and was red); every inconclusive case passes through so a
1187
+ # legitimate completion is never falsely stopped. Default-on; opt out with
1188
+ # LOKI_EVIDENCE_GATE=0 (byte-identical to prior behavior, no read/write).
1189
+ council_evidence_gate() {
1190
+ # Knob first: opt-out is exact-as-today, before any file read or write.
1191
+ [ "${LOKI_EVIDENCE_GATE:-1}" = "0" ] && return 0
1192
+
1193
+ # The gate may run even when the completion council is disabled
1194
+ # (LOKI_COUNCIL_ENABLED=false leaves COUNCIL_STATE_DIR unset by council_init),
1195
+ # because it now also guards the default completion-promise route. Default
1196
+ # the block-report dir to .loki/council so we never write to filesystem root.
1197
+ if [ -z "${COUNCIL_STATE_DIR:-}" ]; then
1198
+ COUNCIL_STATE_DIR="${TARGET_DIR:-.}/.loki/council"
1199
+ fi
1200
+
1201
+ # --- Evidence check (a): nonzero diff vs run-start SHA (committed UNION working tree) ---
1202
+ local base_sha=""
1203
+ if [ -n "${_LOKI_RUN_START_SHA:-}" ]; then
1204
+ base_sha="$_LOKI_RUN_START_SHA"
1205
+ elif [ -f ".loki/state/start-sha" ]; then
1206
+ base_sha="$(cat .loki/state/start-sha 2>/dev/null || echo "")"
1207
+ fi
1208
+
1209
+ # diff_fails stays "false" in every inconclusive branch below (no git repo,
1210
+ # no baseline). The block decision (block iff diff_fails OR test_fails) thus
1211
+ # treats inconclusive as pass-through by construction; no separate flag is
1212
+ # read, so none is tracked (avoids SC2034 dead-assignment).
1213
+ local diff_fails="false"
1214
+ local diff_files=0
1215
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
1216
+ # No git repo => cannot prove fabrication => inconclusive => pass-through.
1217
+ :
1218
+ elif [ -z "$base_sha" ]; then
1219
+ # No baseline captured (non-git/zero-commit run, or never set) =>
1220
+ # inconclusive => pass-through. Never false-block a legit first run.
1221
+ :
1222
+ else
1223
+ # Count the UNION of three change sources (auto-commit is not guaranteed,
1224
+ # so committed-only would false-block a dirty-but-real working tree):
1225
+ # committed since baseline, unstaged, staged.
1226
+ local committed_files unstaged_files staged_files untracked_files
1227
+ if committed_files=$(git diff --name-only "$base_sha" HEAD 2>/dev/null); then
1228
+ :
1229
+ else
1230
+ # Base present but unreachable (e.g. shallow clone): fall back to
1231
+ # working-tree diff vs HEAD (mirrors proof-generator.py fallback).
1232
+ committed_files=$(git diff --name-only HEAD 2>/dev/null || echo "")
1233
+ fi
1234
+ unstaged_files=$(git diff --name-only HEAD 2>/dev/null || echo "")
1235
+ staged_files=$(git diff --cached --name-only 2>/dev/null || echo "")
1236
+ # Untracked new files: a greenfield first run creates files that are not
1237
+ # yet committed, staged, or seen by diff HEAD. Without this fourth source
1238
+ # the union would be empty and the gate would false-block legitimate new
1239
+ # work. --exclude-standard respects .gitignore so build artifacts and
1240
+ # node_modules do not count as evidence.
1241
+ untracked_files=$(git ls-files --others --exclude-standard 2>/dev/null || echo "")
1242
+ # Exclude Loki's own runtime state from the union: .loki/ holds the
1243
+ # gate's inputs (e.g. .loki/quality/test-results.json is always present
1244
+ # at gate time) and other runtime files that are not gitignored, so
1245
+ # counting them would make the gate toothless (the union would never be
1246
+ # empty). Loki's own state is not project work / completion evidence.
1247
+ local union_files
1248
+ union_files=$(printf '%s\n%s\n%s\n%s\n' "$committed_files" "$unstaged_files" "$staged_files" "$untracked_files" | grep -v '^$' | grep -vE '^\.loki/' | sort -u)
1249
+ if [ -n "$union_files" ]; then
1250
+ diff_files=$(printf '%s\n' "$union_files" | wc -l | tr -d ' ')
1251
+ else
1252
+ diff_files=0
1253
+ fi
1254
+ if [ "$diff_files" -eq 0 ]; then
1255
+ diff_fails="true"
1256
+ fi
1257
+ fi
1258
+
1259
+ # --- Evidence check (b): tests green ---
1260
+ local tr_file=".loki/quality/test-results.json"
1261
+ # Like diff_fails, test_fails stays "false" on INCONCLUSIVE / missing-file
1262
+ # branches, so inconclusive is pass-through by construction and no separate
1263
+ # flag is read (avoids SC2034 dead-assignment).
1264
+ local test_fails="false"
1265
+ local test_runner="none"
1266
+ local test_pass="true"
1267
+ if [ -f "$tr_file" ]; then
1268
+ local test_status
1269
+ test_status=$(_TR_FILE="$tr_file" python3 -c "
1270
+ import json, os, sys
1271
+ tr_file = os.environ['_TR_FILE']
1272
+ try:
1273
+ with open(tr_file) as f:
1274
+ d = json.load(f)
1275
+ except (json.JSONDecodeError, IOError, KeyError, ValueError):
1276
+ print('INCONCLUSIVE:none:true')
1277
+ sys.exit(0)
1278
+ runner = d.get('runner', 'none')
1279
+ passed = d.get('pass', True)
1280
+ if runner == 'none':
1281
+ print('PASS:none:true')
1282
+ elif passed is False:
1283
+ print('FAIL:%s:false' % runner)
1284
+ else:
1285
+ print('PASS:%s:true' % runner)
1286
+ " 2>/dev/null || echo "INCONCLUSIVE:none:true")
1287
+ local _verdict="${test_status%%:*}"
1288
+ local _rest="${test_status#*:}"
1289
+ test_runner="${_rest%%:*}"
1290
+ test_pass="${_rest#*:}"
1291
+ if [ "$_verdict" = "FAIL" ]; then
1292
+ test_fails="true"
1293
+ fi
1294
+ # INCONCLUSIVE => test_fails stays "false" => pass-through.
1295
+ fi
1296
+ # Missing test-results.json (the else of the -f check) likewise leaves
1297
+ # test_fails="false" => inconclusive => pass-through (no file = no gate).
1298
+
1299
+ # --- Block decision: block iff DIFF FAILS or TEST FAILS ---
1300
+ if [ "$diff_fails" != "true" ] && [ "$test_fails" != "true" ]; then
1301
+ # Gate passes: remove any stale block report.
1302
+ if [ -f "$COUNCIL_STATE_DIR/evidence-block.json" ]; then
1303
+ rm -f "$COUNCIL_STATE_DIR/evidence-block.json"
1304
+ fi
1305
+ return 0
1306
+ fi
1307
+
1308
+ # Determine reason and build human-readable failure list.
1309
+ local reason="no_evidence_of_completion"
1310
+ if [ "$diff_fails" = "true" ] && [ "$test_fails" = "true" ]; then
1311
+ reason="empty_diff_and_tests_red"
1312
+ elif [ "$diff_fails" = "true" ]; then
1313
+ reason="empty_diff"
1314
+ elif [ "$test_fails" = "true" ]; then
1315
+ reason="tests_red"
1316
+ fi
1317
+
1318
+ local failures=""
1319
+ if [ "$diff_fails" = "true" ]; then
1320
+ failures="empty git diff vs run-start SHA (nothing shipped)"
1321
+ log_warn "[Council] Evidence gate BLOCKED: empty git diff vs run-start SHA"
1322
+ fi
1323
+ if [ "$test_fails" = "true" ]; then
1324
+ if [ -n "$failures" ]; then
1325
+ failures="${failures}|test runner '${test_runner}' ran and was red"
1326
+ else
1327
+ failures="test runner '${test_runner}' ran and was red"
1328
+ fi
1329
+ log_warn "[Council] Evidence gate BLOCKED: test runner '${test_runner}' was red"
1330
+ fi
1331
+
1332
+ # Rail 3 (one-step self-rescue): the terminal user (no dashboard open) must
1333
+ # be told, right at the block site, how to opt out of the gate. A false
1334
+ # block (e.g. a pre-existing red test the run cannot fix) is otherwise a
1335
+ # dead-end until max-iterations. This single line keeps the gate safe to
1336
+ # ship default-on.
1337
+ log_warn "[Council] Run will keep iterating until there is real evidence of completion. To opt out: set LOKI_EVIDENCE_GATE=0"
1338
+
1339
+ # Write block report (atomic temp+mv, mirroring gate-block.json).
1340
+ mkdir -p "$COUNCIL_STATE_DIR" 2>/dev/null || true
1341
+ local ev_file="$COUNCIL_STATE_DIR/evidence-block.json"
1342
+ local ev_tmp="${ev_file}.tmp"
1343
+ local timestamp
1344
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1345
+ local failures_json diff_ok tests_ok base_for_json
1346
+ failures_json=$(_FAILURES="$failures" python3 -c "
1347
+ import json, os
1348
+ items = [s for s in os.environ['_FAILURES'].split('|') if s]
1349
+ print(json.dumps(items[:5]))
1350
+ " 2>/dev/null || echo '[]')
1351
+ if [ "$diff_fails" = "true" ]; then diff_ok="false"; else diff_ok="true"; fi
1352
+ if [ "$test_fails" = "true" ]; then tests_ok="false"; else tests_ok="true"; fi
1353
+ base_for_json="${base_sha:-}"
1354
+ cat > "$ev_tmp" << EVIDENCE_EOF
1355
+ {
1356
+ "status": "blocked",
1357
+ "blocked": true,
1358
+ "blocked_at": "$timestamp",
1359
+ "iteration": ${ITERATION_COUNT:-0},
1360
+ "reason": "$reason",
1361
+ "checks": {
1362
+ "diff": {"ok": $diff_ok, "base_sha": "$base_for_json", "files_changed": $diff_files, "sources": "committed|unstaged|staged|untracked union"},
1363
+ "tests": {"ok": $tests_ok, "runner": "$test_runner", "pass": $test_pass}
1364
+ },
1365
+ "failures": $failures_json
1366
+ }
1367
+ EVIDENCE_EOF
1368
+ mv "$ev_tmp" "$ev_file"
1369
+ return 1
1370
+ }
1371
+
896
1372
  #===============================================================================
897
1373
  # Council Member Review - Individual member evaluation
898
1374
  #===============================================================================
@@ -1524,6 +2000,13 @@ council_evaluate() {
1524
2000
  return 1 # CONTINUE - can't complete with critical failures
1525
2001
  fi
1526
2002
 
2003
+ # Phase 2.5 (v7.19.1): evidence hard gate - block completion unless there is
2004
+ # real evidence that files changed AND tests are green.
2005
+ if ! council_evidence_gate; then
2006
+ log_info "[Council] Completion blocked by evidence hard gate"
2007
+ return 1 # CONTINUE - cannot complete without real evidence
2008
+ fi
2009
+
1527
2010
  # Compute threshold using the same ceiling(2/3) formula as council_vote and council_aggregate_votes
1528
2011
  local _eval_threshold=$(( (COUNCIL_SIZE * 2 + 2) / 3 ))
1529
2012
 
@@ -80,6 +80,32 @@ completion:
80
80
  # Ignore ALL completion signals (runs forever)
81
81
  perpetual_mode: false
82
82
 
83
+ # Uncertainty-gated escalation (v7.19.2, default-on).
84
+ # When >=2 of 3 stuck-proxies (no-change counter, diff-hash oscillation,
85
+ # persistent council split) co-occur for `uncertainty.rounds` consecutive
86
+ # rounds, Loki escalates proactively via PAUSE + notification + handoff
87
+ # instead of silently burning iterations.
88
+ # IMPORTANT: when autonomy_mode is "perpetual" (the default), PAUSE is
89
+ # auto-cleared by the consumer so escalation degrades to notify-only; it
90
+ # does NOT halt the run. These are heuristics, not true metacognition.
91
+ #
92
+ # uncertainty:
93
+ # # Master toggle. Set to 0 to disable (byte-identical when off).
94
+ # escalation: 1
95
+ #
96
+ # # Consecutive rounds where >=2 of 3 proxies must co-occur before
97
+ # # escalating. Recommended range 2-3. Higher = less noise, later warning.
98
+ # rounds: 2
99
+ #
100
+ # # Proxy 1 threshold: consecutive_no_change must reach this value to mark
101
+ # # the no-change proxy hot. Default is COUNCIL_STAGNATION_LIMIT - 1
102
+ # # (one below the circuit-breaker limit). Leave unset to use the default.
103
+ # # nochange_min: 4
104
+ #
105
+ # # Proxy 3 threshold: trailing council verdicts that must be
106
+ # # REJECTED-with-at-least-one-approver (split) to mark the split proxy hot.
107
+ # split_rounds: 2
108
+
83
109
  #===============================================================================
84
110
  # Model & Routing Settings
85
111
  #===============================================================================