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 +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +483 -0
- package/autonomy/config.example.yaml +26 -0
- package/autonomy/run.sh +103 -3
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +40 -11
- package/dashboard/static/index.html +543 -497
- package/docs/INSTALLATION.md +1 -1
- package/docs/UNCERTAINTY-ESCALATION-PLAN.md +396 -0
- package/docs/VERIFIED-COMPLETION-PLAN.md +462 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/skills/quality-gates.md +115 -0
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.
|
|
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.
|
|
386
|
+
**v7.19.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.19.
|
|
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
|
#===============================================================================
|