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 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.113.0
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.113.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.115.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.113.0
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 [ "$circuit_triggered" = "true" ]; then
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
- elif [ $((ITERATION_COUNT % COUNCIL_CHECK_INTERVAL)) -eq 0 ]; then
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,