loki-mode 7.113.0 → 7.115.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 +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +131 -5
- package/autonomy/lib/effort_estimator.py +397 -0
- package/autonomy/lib/proof-generator.py +22 -0
- package/autonomy/prd-checklist.sh +329 -21
- package/autonomy/run.sh +106 -6
- package/autonomy/verify.sh +203 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/lsp_proxy.py +41 -0
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
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.
|
|
6
|
+
# Loki Mode v7.115.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.
|
|
411
|
+
**v7.115.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.115.0
|
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
# LOKI_COUNCIL_THRESHOLD - Votes needed for completion (default: 2)
|
|
26
26
|
# LOKI_COUNCIL_CHECK_INTERVAL - Check every N iterations (default: 5)
|
|
27
27
|
# LOKI_COUNCIL_MIN_ITERATIONS - Minimum iterations before council runs (default: 3)
|
|
28
|
+
# LOKI_COUNCIL_CONVERGENCE_EARLY - No-claim early-check bypass: when evidence is
|
|
29
|
+
# affirmatively green (real suite passed +
|
|
30
|
+
# checklist not failing) the convergence path
|
|
31
|
+
# may evaluate off the CHECK_INTERVAL boundary
|
|
32
|
+
# (>= MIN_ITERATIONS). Default 1; set 0 to
|
|
33
|
+
# restore interval-only behavior (RANK 15).
|
|
28
34
|
# LOKI_COUNCIL_CONVERGENCE_WINDOW - Iterations to track for convergence (default: 3)
|
|
29
35
|
# LOKI_COUNCIL_STAGNATION_LIMIT - Max iterations with no git changes (default: 5)
|
|
30
36
|
# LOKI_COUNCIL_DONE_SIGNAL_LIMIT - Max total done signals before force stop (default: 10)
|
|
@@ -3351,6 +3357,122 @@ PYEOF
|
|
|
3351
3357
|
# Main Entry Point - Should the loop stop?
|
|
3352
3358
|
#===============================================================================
|
|
3353
3359
|
|
|
3360
|
+
#===============================================================================
|
|
3361
|
+
# Convergence-floor early-check helpers (RANK 15)
|
|
3362
|
+
#
|
|
3363
|
+
# Lower the effective convergence floor for the NO-explicit-claim path ONLY.
|
|
3364
|
+
# By default the council convergence path only evaluates on the CHECK_INTERVAL
|
|
3365
|
+
# boundary (every 5th iteration), so a no-promise/analysis run that is verifiably
|
|
3366
|
+
# finished at iteration 2 still grinds to iteration 5 before the council is even
|
|
3367
|
+
# allowed to look. These helpers let that path evaluate as soon as there is
|
|
3368
|
+
# AFFIRMATIVE evidence of done, without weakening any gate: they only flip WHEN
|
|
3369
|
+
# the council evaluates, never WHETHER it approves (council_evaluate still runs
|
|
3370
|
+
# the full checklist / held-out / evidence / assumption gates + the vote + the
|
|
3371
|
+
# devil's advocate on every trigger).
|
|
3372
|
+
#
|
|
3373
|
+
# Two knobs preserve full opt-out to the historical behavior:
|
|
3374
|
+
# LOKI_COUNCIL_CONVERGENCE_EARLY=0 -> disable the early-check bypass entirely
|
|
3375
|
+
# (byte-identical to prior interval-only).
|
|
3376
|
+
# LOKI_COUNCIL_MIN_ITERATIONS -> existing floor (already clamps to >=1);
|
|
3377
|
+
# the early check never fires below it.
|
|
3378
|
+
#===============================================================================
|
|
3379
|
+
|
|
3380
|
+
# _council_convergence_evidence_green: read-only, side-effect-free probe that
|
|
3381
|
+
# returns 0 ONLY when there is AFFIRMATIVE positive evidence a no-promise run is
|
|
3382
|
+
# done -- i.e. a real test suite ran and passed, AND (if a checklist exists) it
|
|
3383
|
+
# has no failing items. Deliberately does NOT reuse council_evidence_gate: that
|
|
3384
|
+
# gate PASSES on inconclusive inputs (no git repo / no baseline / runner=="none"
|
|
3385
|
+
# / missing test-results.json all pass-through). Inconclusive is "not red", not
|
|
3386
|
+
# "green" -- fast-pathing on it would be an unverified early stop. Affirmative
|
|
3387
|
+
# green means tests ACTUALLY ran and passed. Does NOT call
|
|
3388
|
+
# council_reverify_checklist (that has side effects; council_evaluate runs it).
|
|
3389
|
+
_council_convergence_evidence_green() {
|
|
3390
|
+
local tr_file="${TARGET_DIR:-.}/.loki/quality/test-results.json"
|
|
3391
|
+
# Affirmative test-green is REQUIRED: a real runner that passed. A missing
|
|
3392
|
+
# file or runner=="none" (no suite) is NOT affirmative evidence -> not green.
|
|
3393
|
+
[ -f "$tr_file" ] || return 1
|
|
3394
|
+
local tr_state
|
|
3395
|
+
tr_state=$(_TR_FILE="$tr_file" python3 -c "
|
|
3396
|
+
import json, os, sys
|
|
3397
|
+
try:
|
|
3398
|
+
with open(os.environ['_TR_FILE']) as f:
|
|
3399
|
+
d = json.load(f)
|
|
3400
|
+
except (json.JSONDecodeError, IOError, KeyError, ValueError):
|
|
3401
|
+
print('no'); sys.exit(0)
|
|
3402
|
+
runner = d.get('runner', 'none')
|
|
3403
|
+
passed = d.get('pass', True)
|
|
3404
|
+
# Affirmative green requires a REAL suite (runner != none) that did not fail.
|
|
3405
|
+
print('yes' if (runner != 'none' and passed is not False) else 'no')
|
|
3406
|
+
" 2>/dev/null || echo "no")
|
|
3407
|
+
[ "$tr_state" = "yes" ] || return 1
|
|
3408
|
+
|
|
3409
|
+
# If a checklist exists, it must have NO failing items to be affirmatively
|
|
3410
|
+
# green. Absence of a checklist is fine (test-green alone carries the signal);
|
|
3411
|
+
# the council's own hard checklist gate still runs inside council_evaluate.
|
|
3412
|
+
local results_file="${TARGET_DIR:-.}/.loki/checklist/verification-results.json"
|
|
3413
|
+
if [ -f "$results_file" ]; then
|
|
3414
|
+
local cl_state
|
|
3415
|
+
cl_state=$(_RESULTS_FILE="$results_file" python3 -c "
|
|
3416
|
+
import json, os, sys
|
|
3417
|
+
try:
|
|
3418
|
+
with open(os.environ['_RESULTS_FILE']) as f:
|
|
3419
|
+
r = json.load(f)
|
|
3420
|
+
except (json.JSONDecodeError, IOError, KeyError, ValueError):
|
|
3421
|
+
print('no'); sys.exit(0)
|
|
3422
|
+
items = [it for cat in r.get('categories', []) for it in cat.get('items', [])]
|
|
3423
|
+
if not items:
|
|
3424
|
+
# A present-but-empty checklist is not affirmative done-evidence.
|
|
3425
|
+
print('no'); sys.exit(0)
|
|
3426
|
+
failing = any(it.get('status') == 'failing' for it in items)
|
|
3427
|
+
print('no' if failing else 'yes')
|
|
3428
|
+
" 2>/dev/null || echo "no")
|
|
3429
|
+
[ "$cl_state" = "yes" ] || return 1
|
|
3430
|
+
fi
|
|
3431
|
+
return 0
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
# _council_should_check_now: pure decision function -- given the current gate
|
|
3435
|
+
# inputs (via env/args) returns 0 if the council should evaluate this iteration,
|
|
3436
|
+
# 1 otherwise. Extracted so the convergence-floor bypass is unit-testable without
|
|
3437
|
+
# driving a real 3-member vote. Inputs (all read from the shell, mirroring
|
|
3438
|
+
# council_should_stop's locals):
|
|
3439
|
+
# $1 = circuit_triggered ("true"/other)
|
|
3440
|
+
# $2 = completion_claimed ("true"/other)
|
|
3441
|
+
# ITERATION_COUNT, COUNCIL_CHECK_INTERVAL, COUNCIL_MIN_ITERATIONS (globals)
|
|
3442
|
+
# LOKI_COUNCIL_CONVERGENCE_EARLY (knob; default on)
|
|
3443
|
+
# Precedence matches council_should_stop: circuit breaker, then explicit claim,
|
|
3444
|
+
# then interval boundary, then the NEW evidence-green early check (last, so it
|
|
3445
|
+
# never changes the explicit-claim or circuit paths).
|
|
3446
|
+
_council_should_check_now() {
|
|
3447
|
+
local circuit_triggered="${1:-false}"
|
|
3448
|
+
local completion_claimed="${2:-false}"
|
|
3449
|
+
local iter="${ITERATION_COUNT:-0}"
|
|
3450
|
+
local interval="${COUNCIL_CHECK_INTERVAL:-5}"
|
|
3451
|
+
local min_iter="${COUNCIL_MIN_ITERATIONS:-3}"
|
|
3452
|
+
|
|
3453
|
+
# Circuit breaker and explicit-claim paths are UNCHANGED (they already bypass
|
|
3454
|
+
# the interval); return check-now for them without touching the early logic.
|
|
3455
|
+
if [ "$circuit_triggered" = "true" ]; then
|
|
3456
|
+
return 0
|
|
3457
|
+
fi
|
|
3458
|
+
if [ "$completion_claimed" = "true" ]; then
|
|
3459
|
+
return 0
|
|
3460
|
+
fi
|
|
3461
|
+
# Standard interval boundary.
|
|
3462
|
+
if [ $((iter % interval)) -eq 0 ]; then
|
|
3463
|
+
return 0
|
|
3464
|
+
fi
|
|
3465
|
+
# NEW convergence-floor early check (no explicit claim): once we are at/above
|
|
3466
|
+
# the MIN_ITERATIONS floor AND there is affirmative evidence of done, evaluate
|
|
3467
|
+
# now instead of waiting for the next interval boundary. Opt-out preserved.
|
|
3468
|
+
if [ "${LOKI_COUNCIL_CONVERGENCE_EARLY:-1}" != "0" ] \
|
|
3469
|
+
&& [ "$iter" -ge "$min_iter" ] 2>/dev/null \
|
|
3470
|
+
&& _council_convergence_evidence_green; then
|
|
3471
|
+
return 0
|
|
3472
|
+
fi
|
|
3473
|
+
return 1
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3354
3476
|
council_should_stop() {
|
|
3355
3477
|
# bash-F1: reset the force-stop sentinel at entry. A return-0 from the
|
|
3356
3478
|
# genuine approval path leaves this 0; only the safety-valve paths below
|
|
@@ -3413,13 +3535,17 @@ council_should_stop() {
|
|
|
3413
3535
|
completion_claimed=true
|
|
3414
3536
|
fi
|
|
3415
3537
|
|
|
3538
|
+
# Decide WHETHER to convene the council this iteration. Precedence (unchanged
|
|
3539
|
+
# for the circuit-breaker and explicit-claim paths): circuit breaker, explicit
|
|
3540
|
+
# completion claim, interval boundary, then the RANK-15 convergence-floor early
|
|
3541
|
+
# check (no explicit claim + affirmative evidence-green -> evaluate now instead
|
|
3542
|
+
# of waiting for the next interval boundary). _council_should_check_now is a
|
|
3543
|
+
# pure decision helper (unit-tested) so this stays a single call.
|
|
3416
3544
|
local should_check=false
|
|
3417
|
-
if [ "$
|
|
3418
|
-
should_check=true
|
|
3419
|
-
elif [ "$completion_claimed" = "true" ]; then
|
|
3420
|
-
should_check=true
|
|
3545
|
+
if [ "$completion_claimed" = "true" ] && [ "$circuit_triggered" != "true" ]; then
|
|
3421
3546
|
log_info "[Council] Completion claimed this iteration -- evaluating now (not deferring to the ${COUNCIL_CHECK_INTERVAL}-iteration interval)"
|
|
3422
|
-
|
|
3547
|
+
fi
|
|
3548
|
+
if _council_should_check_now "$circuit_triggered" "$completion_claimed"; then
|
|
3423
3549
|
should_check=true
|
|
3424
3550
|
fi
|
|
3425
3551
|
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Engineering-hours estimator for the per-build proof.json (Rank 6).
|
|
3
|
+
|
|
4
|
+
Replaces the naive "iterations x 15min" number (which is blind to the actual
|
|
5
|
+
work) with a WORK-based estimate: it reads the real signals available at
|
|
6
|
+
proof-generation time -- the git diff stat (insertions / deletions / files
|
|
7
|
+
changed), the number of files touched, whether tests were written, and the
|
|
8
|
+
task/PRD scope -- and predicts the equivalent human-engineering-hours plus a
|
|
9
|
+
low/high band.
|
|
10
|
+
|
|
11
|
+
Two methods, chosen HONESTLY at runtime:
|
|
12
|
+
|
|
13
|
+
method="llm" An LLM was available AND returned a parseable number. The
|
|
14
|
+
model id is recorded. Gated behind LOKI_EFFORT_LLM=1 so a
|
|
15
|
+
model is NEVER called on every build by default (cost /
|
|
16
|
+
latency / the 30s proof cap). Any failure -- no key, no
|
|
17
|
+
CLI, timeout, unparseable output -- FAILS OPEN to the
|
|
18
|
+
heuristic. We never emit method="llm" with a number the
|
|
19
|
+
model did not actually give us.
|
|
20
|
+
|
|
21
|
+
method="heuristic" The deterministic work-based fallback. Same inputs, a
|
|
22
|
+
fixed formula, reproducible for identical inputs. This is
|
|
23
|
+
the default path (and the only path in headless CI /
|
|
24
|
+
offline). Labeled uncalibrated -- see `calibrated` below.
|
|
25
|
+
|
|
26
|
+
The estimate is ALWAYS labeled calibrated=false ("uncalibrated") in this lane:
|
|
27
|
+
no ground-truth engineer-hour dataset has scored it yet, so the number is a
|
|
28
|
+
transparent estimate, never presented as validated. A separate calibration
|
|
29
|
+
harness may flip that once an error metric clears a threshold.
|
|
30
|
+
|
|
31
|
+
inputs_hash: sha256 over the canonical inputs (diff stat + files + tests +
|
|
32
|
+
scope). Identical whether the method is llm or heuristic, so the estimate is
|
|
33
|
+
reproducible and auditable -- a reader can recompute the hash from the same
|
|
34
|
+
proof and confirm the estimator saw exactly these inputs.
|
|
35
|
+
|
|
36
|
+
This module is import-only and never raises to the caller: estimate() catches
|
|
37
|
+
everything and falls open to the heuristic (and, in the worst case, a zeroed
|
|
38
|
+
estimate) so proof.json generation can never be broken by it.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import hashlib
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import subprocess
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Schema of the emitted effort_estimate object. Bumped if the shape changes so
|
|
49
|
+
# a reader can tell which estimator produced a given field set.
|
|
50
|
+
ESTIMATOR_VERSION = "1"
|
|
51
|
+
|
|
52
|
+
# The brief is stored truncated to this many chars in proof.json (matches
|
|
53
|
+
# proof-generator.generate()'s brief[:600]); the hashed brief_len is capped to
|
|
54
|
+
# the same bound so inputs_hash is recomputable from the written receipt.
|
|
55
|
+
_BRIEF_HASH_CAP = 600
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _canonical(obj):
|
|
59
|
+
"""Stable JSON serialization for hashing (sorted keys, compact)."""
|
|
60
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"),
|
|
61
|
+
ensure_ascii=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _to_int(v, default=0):
|
|
65
|
+
try:
|
|
66
|
+
return int(v)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return default
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _canonical_inputs(files_changed, tests, spec, iterations):
|
|
72
|
+
"""The exact inputs the estimate is a function of, in a canonical shape.
|
|
73
|
+
|
|
74
|
+
Only work signals -- NOT wall-clock or cost, which are noisy and would make
|
|
75
|
+
the hash non-reproducible across machines. Iteration count is carried as a
|
|
76
|
+
weak secondary signal (it slightly widens the band) but is deliberately not
|
|
77
|
+
the primary driver: the backlog AC requires the estimate reflect real work,
|
|
78
|
+
not iteration count alone.
|
|
79
|
+
"""
|
|
80
|
+
fc = files_changed or {}
|
|
81
|
+
files = fc.get("files") or []
|
|
82
|
+
t = tests or {}
|
|
83
|
+
sp = spec or {}
|
|
84
|
+
it = iterations or {}
|
|
85
|
+
return {
|
|
86
|
+
"diff": {
|
|
87
|
+
"count": _to_int(fc.get("count")),
|
|
88
|
+
"insertions": _to_int(fc.get("insertions")),
|
|
89
|
+
"deletions": _to_int(fc.get("deletions")),
|
|
90
|
+
# Sorted file paths so ordering never perturbs the hash.
|
|
91
|
+
"files": sorted(
|
|
92
|
+
str(f.get("path", "")) for f in files if isinstance(f, dict)
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
"tests": {
|
|
96
|
+
"status": str(t.get("status") or "not_run"),
|
|
97
|
+
"passed_count": _to_int(t.get("passed_count")),
|
|
98
|
+
"failed_count": _to_int(t.get("failed_count")),
|
|
99
|
+
},
|
|
100
|
+
"scope": {
|
|
101
|
+
# NOTE: the raw spec source is a filesystem PATH (e.g. an absolute
|
|
102
|
+
# path into the build's temp dir), which is build-location noise, NOT
|
|
103
|
+
# a work signal -- hashing it would make identical work produce
|
|
104
|
+
# different hashes across machines/dirs. We hash only whether a spec
|
|
105
|
+
# was present, plus the brief length (a real proxy for scope size).
|
|
106
|
+
# The full brief text is not hashed (brittle to whitespace edits).
|
|
107
|
+
"has_spec": bool(str(sp.get("source") or "")
|
|
108
|
+
not in ("", "codebase-analysis")),
|
|
109
|
+
# Cap the hashed length at the SAME 600-char bound the generator
|
|
110
|
+
# applies to spec.brief before writing proof.json (generate() does
|
|
111
|
+
# brief[:600] after the estimate is computed). Without this cap, a
|
|
112
|
+
# brief longer than 600 chars would hash the full length while the
|
|
113
|
+
# receipt stores only 600, so a third party recomputing inputs_hash
|
|
114
|
+
# from the written proof.json would get a different value -- the hash
|
|
115
|
+
# would be self-consistent but NOT reproducible from the artifact,
|
|
116
|
+
# defeating the auditability the hash exists for.
|
|
117
|
+
"brief_len": min(len(str(sp.get("brief") or "")), _BRIEF_HASH_CAP),
|
|
118
|
+
},
|
|
119
|
+
"iterations": _to_int(it.get("count")),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _inputs_hash(canon_inputs):
|
|
124
|
+
return hashlib.sha256(
|
|
125
|
+
_canonical(canon_inputs).encode("utf-8")
|
|
126
|
+
).hexdigest()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def heuristic_estimate(canon_inputs):
|
|
130
|
+
"""Deterministic WORK-based hours from the canonical inputs.
|
|
131
|
+
|
|
132
|
+
Model (transparent + reproducible):
|
|
133
|
+
- A baseline per changed file (reading, wiring, context switching).
|
|
134
|
+
- A per-line component on net churn (insertions + deletions), the bulk of
|
|
135
|
+
the signal, scaled to a rough "lines of considered code per hour".
|
|
136
|
+
- A test bonus: writing tests is real engineering effort the line count
|
|
137
|
+
alone under-counts.
|
|
138
|
+
- A scope bonus from the PRD/brief size (bigger specs = more design).
|
|
139
|
+
|
|
140
|
+
Returns (hours, low, high) rounded to 2 decimals. The band is a fixed
|
|
141
|
+
+/- fraction, slightly widened by iteration count (more back-and-forth =
|
|
142
|
+
more uncertainty). Every constant is a stated assumption, NOT a calibrated
|
|
143
|
+
value -- hence the estimate is always labeled uncalibrated.
|
|
144
|
+
"""
|
|
145
|
+
diff = canon_inputs.get("diff", {})
|
|
146
|
+
files = diff.get("files", []) or []
|
|
147
|
+
n_files = _to_int(diff.get("count")) or len(files)
|
|
148
|
+
churn = _to_int(diff.get("insertions")) + _to_int(diff.get("deletions"))
|
|
149
|
+
|
|
150
|
+
# Test signal: from the tests block AND from test-looking changed files.
|
|
151
|
+
tests = canon_inputs.get("tests", {})
|
|
152
|
+
test_files = 0
|
|
153
|
+
for p in files:
|
|
154
|
+
pl = str(p).lower()
|
|
155
|
+
if any(kw in pl for kw in
|
|
156
|
+
("test", "spec", ".test.", ".spec.", "_test.", "_spec.")):
|
|
157
|
+
test_files += 1
|
|
158
|
+
tests_ran = str(tests.get("status")) in ("verified", "failed", "passed")
|
|
159
|
+
|
|
160
|
+
# --- constants (assumptions, uncalibrated) ---
|
|
161
|
+
PER_FILE_HOURS = 0.15 # ~9 min of context per file touched
|
|
162
|
+
LINES_PER_HOUR = 40.0 # considered (not typed) lines per hour
|
|
163
|
+
TEST_FILE_BONUS_HOURS = 0.35 # writing a test file is real effort
|
|
164
|
+
TESTS_RAN_BONUS_HOURS = 0.25 # having a runnable, green/red suite at all
|
|
165
|
+
SCOPE_HOURS_PER_1K_CHARS = 0.5 # design time scaling with brief size
|
|
166
|
+
|
|
167
|
+
hours = 0.0
|
|
168
|
+
hours += n_files * PER_FILE_HOURS
|
|
169
|
+
hours += (churn / LINES_PER_HOUR) if churn > 0 else 0.0
|
|
170
|
+
hours += test_files * TEST_FILE_BONUS_HOURS
|
|
171
|
+
if tests_ran:
|
|
172
|
+
hours += TESTS_RAN_BONUS_HOURS
|
|
173
|
+
brief_len = _to_int((canon_inputs.get("scope") or {}).get("brief_len"))
|
|
174
|
+
hours += (brief_len / 1000.0) * SCOPE_HOURS_PER_1K_CHARS
|
|
175
|
+
|
|
176
|
+
# A build that changed nothing is genuinely ~0 human-hours; do not invent
|
|
177
|
+
# effort from a bare iteration count.
|
|
178
|
+
if n_files == 0 and churn == 0:
|
|
179
|
+
hours = 0.0
|
|
180
|
+
|
|
181
|
+
hours = round(hours, 2)
|
|
182
|
+
|
|
183
|
+
# Band: +/- 40% base, widened up to +20% more by iteration churn.
|
|
184
|
+
iters = _to_int(canon_inputs.get("iterations"))
|
|
185
|
+
widen = min(iters * 0.02, 0.20)
|
|
186
|
+
low = round(hours * (1.0 - min(0.40 + widen, 0.75)), 2)
|
|
187
|
+
high = round(hours * (1.0 + 0.40 + widen), 2)
|
|
188
|
+
if low < 0:
|
|
189
|
+
low = 0.0
|
|
190
|
+
return hours, low, high
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_HOURS_RE = re.compile(r"(-?\d+(?:\.\d+)?)")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_llm_hours(text):
|
|
197
|
+
"""Extract (hours, low, high) from an LLM completion, or None.
|
|
198
|
+
|
|
199
|
+
Accepts a JSON object {"hours":..,"low":..,"high":..} anywhere in the
|
|
200
|
+
output (preferred), else falls back to the first number as hours. Returns
|
|
201
|
+
None on anything unparseable so the caller fails open. NEVER guesses a band
|
|
202
|
+
it was not given: if only hours is found, low/high are derived as a fixed
|
|
203
|
+
band around it (and the method is still honestly "llm" -- the CENTRAL number
|
|
204
|
+
came from the model)."""
|
|
205
|
+
if not text or not str(text).strip():
|
|
206
|
+
return None
|
|
207
|
+
s = str(text)
|
|
208
|
+
# Prefer an embedded JSON object.
|
|
209
|
+
for m in re.finditer(r"\{[^{}]*\}", s):
|
|
210
|
+
try:
|
|
211
|
+
obj = json.loads(m.group(0))
|
|
212
|
+
except Exception:
|
|
213
|
+
continue
|
|
214
|
+
if isinstance(obj, dict) and "hours" in obj:
|
|
215
|
+
try:
|
|
216
|
+
h = float(obj["hours"])
|
|
217
|
+
except (TypeError, ValueError):
|
|
218
|
+
continue
|
|
219
|
+
if h < 0:
|
|
220
|
+
continue
|
|
221
|
+
low = obj.get("low")
|
|
222
|
+
high = obj.get("high")
|
|
223
|
+
try:
|
|
224
|
+
low = float(low) if low is not None else round(h * 0.6, 2)
|
|
225
|
+
except (TypeError, ValueError):
|
|
226
|
+
low = round(h * 0.6, 2)
|
|
227
|
+
try:
|
|
228
|
+
high = float(high) if high is not None else round(h * 1.4, 2)
|
|
229
|
+
except (TypeError, ValueError):
|
|
230
|
+
high = round(h * 1.4, 2)
|
|
231
|
+
return round(h, 2), round(low, 2), round(high, 2)
|
|
232
|
+
# Fallback: first bare number in the text.
|
|
233
|
+
m = _HOURS_RE.search(s)
|
|
234
|
+
if m:
|
|
235
|
+
try:
|
|
236
|
+
h = float(m.group(1))
|
|
237
|
+
except ValueError:
|
|
238
|
+
return None
|
|
239
|
+
if h < 0:
|
|
240
|
+
return None
|
|
241
|
+
return round(h, 2), round(h * 0.6, 2), round(h * 1.4, 2)
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _llm_estimate(canon_inputs, timeout=18):
|
|
246
|
+
"""Try an LLM estimate. Returns (hours, low, high, model) or None.
|
|
247
|
+
|
|
248
|
+
Gated behind LOKI_EFFORT_LLM=1 so a model is never invoked on every build.
|
|
249
|
+
Honors LOKI_EFFORT_LLM_STUB (its OWN stub env, NOT the wiki one) for
|
|
250
|
+
deterministic tests: a file path or literal completion string. Any failure
|
|
251
|
+
returns None -> caller falls open to the heuristic.
|
|
252
|
+
"""
|
|
253
|
+
if os.environ.get("LOKI_EFFORT_LLM", "") not in ("1", "true", "yes", "on"):
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
prompt = _build_prompt(canon_inputs)
|
|
257
|
+
|
|
258
|
+
stub = os.environ.get("LOKI_EFFORT_LLM_STUB")
|
|
259
|
+
if stub is not None:
|
|
260
|
+
completion = _read_stub(stub)
|
|
261
|
+
model = os.environ.get("LOKI_EFFORT_LLM_MODEL", "stub")
|
|
262
|
+
parsed = _parse_llm_hours(completion)
|
|
263
|
+
if parsed is None:
|
|
264
|
+
return None
|
|
265
|
+
h, low, high = parsed
|
|
266
|
+
return h, low, high, model
|
|
267
|
+
|
|
268
|
+
provider = os.environ.get("LOKI_PROVIDER", "claude")
|
|
269
|
+
cmds = {
|
|
270
|
+
"claude": ["claude", "-p", prompt],
|
|
271
|
+
"codex": ["codex", "exec", "--sandbox", "read-only", prompt],
|
|
272
|
+
"cline": ["cline", "-y", prompt],
|
|
273
|
+
"aider": ["aider", "--message", prompt, "--yes-always",
|
|
274
|
+
"--no-auto-commits"],
|
|
275
|
+
}
|
|
276
|
+
base = cmds.get(provider)
|
|
277
|
+
if base is None or not _which(base[0]):
|
|
278
|
+
return None
|
|
279
|
+
model = os.environ.get("SESSION_MODEL") or provider
|
|
280
|
+
try:
|
|
281
|
+
out = subprocess.run(
|
|
282
|
+
base, capture_output=True, text=True, timeout=timeout,
|
|
283
|
+
stdin=subprocess.DEVNULL,
|
|
284
|
+
)
|
|
285
|
+
except (OSError, subprocess.SubprocessError):
|
|
286
|
+
return None
|
|
287
|
+
if out.returncode != 0 and not (out.stdout or "").strip():
|
|
288
|
+
return None
|
|
289
|
+
parsed = _parse_llm_hours(out.stdout)
|
|
290
|
+
if parsed is None:
|
|
291
|
+
return None
|
|
292
|
+
h, low, high = parsed
|
|
293
|
+
return h, low, high, model
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _build_prompt(canon_inputs):
|
|
297
|
+
diff = canon_inputs.get("diff", {})
|
|
298
|
+
return (
|
|
299
|
+
"You are estimating how many hours a competent human software engineer "
|
|
300
|
+
"would take to produce EXACTLY this change, from scratch, including "
|
|
301
|
+
"design, implementation, and testing. Respond with ONLY a JSON object "
|
|
302
|
+
"of the form {\"hours\": <number>, \"low\": <number>, \"high\": "
|
|
303
|
+
"<number>} and nothing else.\n\n"
|
|
304
|
+
"Change signals:\n"
|
|
305
|
+
"- files changed: %d\n"
|
|
306
|
+
"- lines inserted: %d\n"
|
|
307
|
+
"- lines deleted: %d\n"
|
|
308
|
+
"- test status: %s\n"
|
|
309
|
+
"- scope brief length (chars): %d\n"
|
|
310
|
+
% (
|
|
311
|
+
_to_int(diff.get("count")),
|
|
312
|
+
_to_int(diff.get("insertions")),
|
|
313
|
+
_to_int(diff.get("deletions")),
|
|
314
|
+
str((canon_inputs.get("tests") or {}).get("status")),
|
|
315
|
+
_to_int((canon_inputs.get("scope") or {}).get("brief_len")),
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _read_stub(stub):
|
|
321
|
+
if os.path.sep in stub or stub.endswith(".txt") or stub.endswith(".json"):
|
|
322
|
+
if os.path.isfile(stub):
|
|
323
|
+
try:
|
|
324
|
+
with open(stub, encoding="utf-8", errors="replace") as f:
|
|
325
|
+
return f.read()
|
|
326
|
+
except OSError:
|
|
327
|
+
return ""
|
|
328
|
+
return stub
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _which(name):
|
|
332
|
+
for d in os.environ.get("PATH", "").split(os.pathsep):
|
|
333
|
+
cand = os.path.join(d, name)
|
|
334
|
+
if os.path.isfile(cand) and os.access(cand, os.X_OK):
|
|
335
|
+
return cand
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def estimate(files_changed, tests, spec, iterations):
|
|
340
|
+
"""Return the effort_estimate object for proof.json. Never raises.
|
|
341
|
+
|
|
342
|
+
Shape:
|
|
343
|
+
{
|
|
344
|
+
hours, low, high, # equivalent human-engineering-hours
|
|
345
|
+
method: "llm" | "heuristic",
|
|
346
|
+
model, # model id on the llm path, else ""
|
|
347
|
+
inputs_hash, # sha256 over the canonical inputs
|
|
348
|
+
calibrated, # always False in this lane
|
|
349
|
+
label, # human-facing honesty label
|
|
350
|
+
estimator_version,
|
|
351
|
+
}
|
|
352
|
+
"""
|
|
353
|
+
try:
|
|
354
|
+
canon = _canonical_inputs(files_changed, tests, spec, iterations)
|
|
355
|
+
except Exception:
|
|
356
|
+
canon = {"diff": {"count": 0, "insertions": 0, "deletions": 0,
|
|
357
|
+
"files": []}, "tests": {}, "scope": {},
|
|
358
|
+
"iterations": 0}
|
|
359
|
+
try:
|
|
360
|
+
ihash = _inputs_hash(canon)
|
|
361
|
+
except Exception:
|
|
362
|
+
ihash = ""
|
|
363
|
+
|
|
364
|
+
method = "heuristic"
|
|
365
|
+
model = ""
|
|
366
|
+
hours = low = high = 0.0
|
|
367
|
+
|
|
368
|
+
# LLM path first (opt-in); fail open to heuristic on ANY problem.
|
|
369
|
+
try:
|
|
370
|
+
llm = _llm_estimate(canon)
|
|
371
|
+
except Exception:
|
|
372
|
+
llm = None
|
|
373
|
+
if llm is not None:
|
|
374
|
+
hours, low, high, model = llm
|
|
375
|
+
method = "llm"
|
|
376
|
+
else:
|
|
377
|
+
try:
|
|
378
|
+
hours, low, high = heuristic_estimate(canon)
|
|
379
|
+
except Exception:
|
|
380
|
+
hours, low, high = 0.0, 0.0, 0.0
|
|
381
|
+
method = "heuristic"
|
|
382
|
+
model = ""
|
|
383
|
+
|
|
384
|
+
label = (
|
|
385
|
+
"estimated (uncalibrated, %s)" % method
|
|
386
|
+
)
|
|
387
|
+
return {
|
|
388
|
+
"hours": hours,
|
|
389
|
+
"low": low,
|
|
390
|
+
"high": high,
|
|
391
|
+
"method": method,
|
|
392
|
+
"model": model,
|
|
393
|
+
"inputs_hash": ihash,
|
|
394
|
+
"calibrated": False,
|
|
395
|
+
"label": label,
|
|
396
|
+
"estimator_version": ESTIMATOR_VERSION,
|
|
397
|
+
}
|
|
@@ -36,6 +36,7 @@ if _HERE not in sys.path:
|
|
|
36
36
|
|
|
37
37
|
import proof_redact # noqa: E402
|
|
38
38
|
from efficiency_cost import collect_efficiency as _collect_efficiency # noqa: E402
|
|
39
|
+
import effort_estimator # noqa: E402
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
# ---------------------------------------------------------------------------
|
|
@@ -741,6 +742,25 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
|
|
|
741
742
|
"evidence_gate": evidence_gate,
|
|
742
743
|
}
|
|
743
744
|
|
|
745
|
+
# Effort estimate (Rank 6): equivalent human-engineering-hours for this
|
|
746
|
+
# build, from the REAL work signals (diff stat + files + tests + scope), NOT
|
|
747
|
+
# the iteration count alone. Emitted as a TOP-LEVEL additive key on purpose:
|
|
748
|
+
# it is an ESTIMATE (an opinion, LLM on the opt-in path), so it must never
|
|
749
|
+
# sit under `facts` where _compute_headline/_compute_degraded could let it
|
|
750
|
+
# leak into the deterministic VERIFIED headline. Fail-open: never raises,
|
|
751
|
+
# defaults to the deterministic heuristic when no model is available.
|
|
752
|
+
try:
|
|
753
|
+
effort_estimate = effort_estimator.estimate(
|
|
754
|
+
files_changed, tests, spec, iterations
|
|
755
|
+
)
|
|
756
|
+
except Exception:
|
|
757
|
+
effort_estimate = {
|
|
758
|
+
"hours": 0.0, "low": 0.0, "high": 0.0,
|
|
759
|
+
"method": "heuristic", "model": "", "inputs_hash": "",
|
|
760
|
+
"calibrated": False, "label": "estimated (uncalibrated, heuristic)",
|
|
761
|
+
"estimator_version": effort_estimator.ESTIMATOR_VERSION,
|
|
762
|
+
}
|
|
763
|
+
|
|
744
764
|
# Assemble WITHOUT redaction / verification fields (advisor ordering).
|
|
745
765
|
# Top-level flat keys are RETAINED as a back-compat mirror so existing
|
|
746
766
|
# dashboard/CLI/template readers (schema v1.0 consumers) keep working; the
|
|
@@ -761,6 +781,8 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
|
|
|
761
781
|
"quality_gates": quality_gates,
|
|
762
782
|
"cost": cost,
|
|
763
783
|
"deployment": deployment,
|
|
784
|
+
# Rank 6: work-based engineering-hours estimate (top-level, NOT a fact).
|
|
785
|
+
"effort_estimate": effort_estimate,
|
|
764
786
|
# v1.1 evidence model (additive).
|
|
765
787
|
"facts": facts,
|
|
766
788
|
"assessments": assessments,
|