loki-mode 7.114.0 → 7.116.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/lib/effort_estimator.py +397 -0
- package/autonomy/lib/proof-generator.py +22 -0
- package/autonomy/run.sh +51 -2
- package/autonomy/verify.sh +238 -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/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.116.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.116.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.116.0
|
|
@@ -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,
|
package/autonomy/run.sh
CHANGED
|
@@ -1668,7 +1668,7 @@ _loki_trust_run_id() {
|
|
|
1668
1668
|
# Args: $1 = the new phase value (REASONING, BUILDING, VERIFYING, COMPLETED, etc.)
|
|
1669
1669
|
_advance_current_phase() {
|
|
1670
1670
|
local new_phase="${1:?phase required}"
|
|
1671
|
-
local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}
|
|
1671
|
+
local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
|
|
1672
1672
|
local orch="$loki_dir/state/orchestrator.json"
|
|
1673
1673
|
[ -f "$orch" ] || return 0
|
|
1674
1674
|
# Values are passed via argv (not interpolated into the source) so a phase or
|
|
@@ -9159,6 +9159,51 @@ sys.stdout.write(t.strip())
|
|
|
9159
9159
|
details="cargo test: $(echo "$output" | tail -3 | tr '\n' ' ')"
|
|
9160
9160
|
fi
|
|
9161
9161
|
|
|
9162
|
+
# node --test (built-in Node test runner) -- config-less fallback (task #79).
|
|
9163
|
+
# Node's built-in runner (stable since Node 18) runs *.test.{js,mjs,cjs}
|
|
9164
|
+
# with ZERO config and NO package.json. A deliverable of slug.js +
|
|
9165
|
+
# slug.test.js (a real, passing suite) previously fell straight through to
|
|
9166
|
+
# runner="none" and recorded verification_gap="source_without_tests" -- a
|
|
9167
|
+
# FALSE-NEGATIVE trust defect (symmetric to fake-green): genuinely-correct,
|
|
9168
|
+
# fully-tested work read as NOT VERIFIED. This branch fires ONLY as a
|
|
9169
|
+
# fallback below every explicit-framework path (vitest/jest/mocha/scripts.test
|
|
9170
|
+
# via package.json, monorepo, pytest, go, cargo all gate on
|
|
9171
|
+
# test_runner=="none"), so package.json-with-jest still selects jest. It runs
|
|
9172
|
+
# only when node is on PATH AND *.test.{js,mjs,cjs} files exist at root or
|
|
9173
|
+
# under test/ or tests/. A failing suite records test_passed=false (never
|
|
9174
|
+
# swallowed); absence of node or test files falls through to the honest
|
|
9175
|
+
# "none"/inconclusive path below (never fabricates a pass).
|
|
9176
|
+
if [ "$test_runner" = "none" ] && command -v node &>/dev/null; then
|
|
9177
|
+
local _nt_files=()
|
|
9178
|
+
local _nt_f
|
|
9179
|
+
# Root-level test files (maxdepth 1) plus test/ and tests/ dirs, skipping
|
|
9180
|
+
# vendored trees so we never walk node_modules.
|
|
9181
|
+
while IFS= read -r _nt_f; do
|
|
9182
|
+
[ -n "$_nt_f" ] && _nt_files+=("$_nt_f")
|
|
9183
|
+
done < <(find "${TARGET_DIR:-.}" -maxdepth 1 -type f \
|
|
9184
|
+
\( -name '*.test.js' -o -name '*.test.mjs' -o -name '*.test.cjs' \) \
|
|
9185
|
+
2>/dev/null)
|
|
9186
|
+
for _nt_dir in test tests; do
|
|
9187
|
+
[ -d "${TARGET_DIR:-.}/$_nt_dir" ] || continue
|
|
9188
|
+
while IFS= read -r _nt_f; do
|
|
9189
|
+
[ -n "$_nt_f" ] && _nt_files+=("$_nt_f")
|
|
9190
|
+
done < <(find "${TARGET_DIR:-.}/$_nt_dir" -maxdepth 3 -type f \
|
|
9191
|
+
\( -name '*.test.js' -o -name '*.test.mjs' -o -name '*.test.cjs' \) \
|
|
9192
|
+
-not -path '*/node_modules/*' 2>/dev/null)
|
|
9193
|
+
done
|
|
9194
|
+
if [ "${#_nt_files[@]}" -gt 0 ]; then
|
|
9195
|
+
test_runner="node-test"
|
|
9196
|
+
local output
|
|
9197
|
+
# Pass matched files explicitly (node --test globbing is
|
|
9198
|
+
# Node-version-sensitive); quote each so paths with spaces survive.
|
|
9199
|
+
output=$(cd "${TARGET_DIR:-.}" && timeout "${LOKI_GATE_TIMEOUT:-300}" \
|
|
9200
|
+
node --test "${_nt_files[@]}" 2>&1) || test_passed=false
|
|
9201
|
+
# tail -14 so node's TAP summary block (# tests / # pass N / # fail N,
|
|
9202
|
+
# ~10 lines) survives truncation for the best-effort count parse below.
|
|
9203
|
+
details="node --test: $(echo "$output" | tail -14 | tr '\n' ' ')"
|
|
9204
|
+
fi
|
|
9205
|
+
fi
|
|
9206
|
+
|
|
9162
9207
|
if [ "$test_runner" = "none" ]; then
|
|
9163
9208
|
log_info "Test coverage: no test runner detected, recording inconclusive (not pass)"
|
|
9164
9209
|
# v7.41.x fail-open fix: previously this wrote pass:true, so a project
|
|
@@ -9258,6 +9303,10 @@ TREOF
|
|
|
9258
9303
|
local _tr_passed_n _tr_failed_n
|
|
9259
9304
|
_tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
|
|
9260
9305
|
_tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
|
|
9306
|
+
# node --test emits TAP-ish "# pass N" / "# fail N" summary lines, which the
|
|
9307
|
+
# "N passed"/"N failed" pattern above does not match. Fall back to those.
|
|
9308
|
+
[ -n "$_tr_passed_n" ] || _tr_passed_n=$(printf '%s' "$details" | grep -oE '# pass [0-9]+' | grep -oE '[0-9]+' | head -1)
|
|
9309
|
+
[ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '# fail [0-9]+' | grep -oE '[0-9]+' | head -1)
|
|
9261
9310
|
[ -n "$_tr_passed_n" ] || _tr_passed_n=null
|
|
9262
9311
|
[ -n "$_tr_failed_n" ] || _tr_failed_n=null
|
|
9263
9312
|
|
|
@@ -19316,7 +19365,7 @@ main() {
|
|
|
19316
19365
|
# engine's own is_completed() recognise this session as terminal. Write the
|
|
19317
19366
|
# file-system COMPLETED marker (checked by is_completed() in the main loop).
|
|
19318
19367
|
_advance_current_phase "COMPLETED"
|
|
19319
|
-
local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}
|
|
19368
|
+
local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
|
|
19320
19369
|
echo "Session completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$loki_dir/COMPLETED" 2>/dev/null || true
|
|
19321
19370
|
|
|
19322
19371
|
# Finish-and-own (v7.88.0): write a plain-English ownership handoff
|
package/autonomy/verify.sh
CHANGED
|
@@ -170,6 +170,71 @@ verify_diff_base() {
|
|
|
170
170
|
return 0
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Code-scope / locality record (rank 10, ADVISORY-FIRST).
|
|
175
|
+
#
|
|
176
|
+
# Turns the already-computed VERIFY_DIFF_FILES / VERIFY_DIFF_INS / VERIFY_DIFF_DEL
|
|
177
|
+
# into a structured scope record: {files_changed, net_lines, verdict, thresholds}.
|
|
178
|
+
# net_lines is line GROWTH (insertions - deletions), matching "net line growth" --
|
|
179
|
+
# it goes negative on deletion-heavy diffs (only growth trips a threshold).
|
|
180
|
+
#
|
|
181
|
+
# ADVISORY BY DEFAULT. The verdict is a LABEL in the record only; this helper does
|
|
182
|
+
# NOT emit a gate row and does NOT feed verify_compute_verdict, so it can never
|
|
183
|
+
# change the exit code or block a build (the friction-regression guard, and the
|
|
184
|
+
# greenfield byte-identity guarantee -- loki start never reaches verify_main).
|
|
185
|
+
#
|
|
186
|
+
# Thresholds:
|
|
187
|
+
# - Soft thresholds (LOKI_SCOPE_SOFT_FILES / LOKI_SCOPE_SOFT_NET_LINES) have sane
|
|
188
|
+
# defaults; exceeding a soft threshold with NO hard cap set -> verdict "warn".
|
|
189
|
+
# - Hard caps (LOKI_SCOPE_MAX_FILES / LOKI_SCOPE_MAX_NET_LINES) are OPT-IN: only
|
|
190
|
+
# when such an env var is EXPLICITLY SET and exceeded does the verdict become
|
|
191
|
+
# "block". Even then this record does not by itself block the build; it is the
|
|
192
|
+
# opt-in signal a consumer/enforcer can act on.
|
|
193
|
+
# - Under all applicable thresholds -> "ok".
|
|
194
|
+
#
|
|
195
|
+
# Sets globals: VERIFY_SCOPE_FILES VERIFY_SCOPE_NET_LINES VERIFY_SCOPE_VERDICT
|
|
196
|
+
# VERIFY_SCOPE_SOFT_FILES VERIFY_SCOPE_SOFT_NET_LINES
|
|
197
|
+
# VERIFY_SCOPE_MAX_FILES VERIFY_SCOPE_MAX_NET_LINES (empty when unset)
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
verify_scope_record() {
|
|
200
|
+
local files="${VERIFY_DIFF_FILES:-0}"
|
|
201
|
+
local ins="${VERIFY_DIFF_INS:-0}"
|
|
202
|
+
local del="${VERIFY_DIFF_DEL:-0}"
|
|
203
|
+
local net=$(( ins - del ))
|
|
204
|
+
|
|
205
|
+
# Soft advisory thresholds (defaults; overridable).
|
|
206
|
+
local soft_files="${LOKI_SCOPE_SOFT_FILES:-25}"
|
|
207
|
+
local soft_net="${LOKI_SCOPE_SOFT_NET_LINES:-500}"
|
|
208
|
+
# Hard caps are opt-in: unset means "never block".
|
|
209
|
+
local max_files="${LOKI_SCOPE_MAX_FILES:-}"
|
|
210
|
+
local max_net="${LOKI_SCOPE_MAX_NET_LINES:-}"
|
|
211
|
+
|
|
212
|
+
local verdict="ok"
|
|
213
|
+
|
|
214
|
+
# Hard-cap check first (only when explicitly set) -> "block".
|
|
215
|
+
if [ -n "$max_files" ] && [ "$files" -gt "$max_files" ] 2>/dev/null; then
|
|
216
|
+
verdict="block"
|
|
217
|
+
elif [ -n "$max_net" ] && [ "$net" -gt "$max_net" ] 2>/dev/null; then
|
|
218
|
+
verdict="block"
|
|
219
|
+
# Soft threshold (advisory, no hard cap tripped) -> "warn".
|
|
220
|
+
elif [ "$files" -gt "$soft_files" ] 2>/dev/null; then
|
|
221
|
+
verdict="warn"
|
|
222
|
+
elif [ "$net" -gt "$soft_net" ] 2>/dev/null; then
|
|
223
|
+
verdict="warn"
|
|
224
|
+
fi
|
|
225
|
+
|
|
226
|
+
VERIFY_SCOPE_FILES="$files"
|
|
227
|
+
VERIFY_SCOPE_NET_LINES="$net"
|
|
228
|
+
VERIFY_SCOPE_VERDICT="$verdict"
|
|
229
|
+
VERIFY_SCOPE_SOFT_FILES="$soft_files"
|
|
230
|
+
VERIFY_SCOPE_SOFT_NET_LINES="$soft_net"
|
|
231
|
+
VERIFY_SCOPE_MAX_FILES="$max_files"
|
|
232
|
+
VERIFY_SCOPE_MAX_NET_LINES="$max_net"
|
|
233
|
+
|
|
234
|
+
_verify_log "scope: $files files, net $net lines -> $verdict (soft $soft_files/$soft_net, hard ${max_files:-unset}/${max_net:-unset})"
|
|
235
|
+
return 0
|
|
236
|
+
}
|
|
237
|
+
|
|
173
238
|
# ---------------------------------------------------------------------------
|
|
174
239
|
# Gate: build (faithful extension of run.sh language detection).
|
|
175
240
|
#
|
|
@@ -318,6 +383,41 @@ verify_gate_tests() {
|
|
|
318
383
|
fi
|
|
319
384
|
fi
|
|
320
385
|
|
|
386
|
+
# node --test (built-in Node runner) -- config-less fallback (task #79).
|
|
387
|
+
# Node's built-in test runner (stable since Node 18) runs
|
|
388
|
+
# *.test.{js,mjs,cjs} with ZERO config and NO package.json, so a real
|
|
389
|
+
# passing suite (slug.js + slug.test.js, no package.json) previously fell
|
|
390
|
+
# through to runner="none" -> tests "skipped" -- a FALSE-NEGATIVE on the
|
|
391
|
+
# verdict surface (symmetric to fake-green). This branch fires ONLY below
|
|
392
|
+
# the explicit-framework paths (vitest/jest/mocha via package.json all set
|
|
393
|
+
# runner above), so package.json-with-jest still selects jest. Runs only
|
|
394
|
+
# when node is on PATH AND *.test.{js,mjs,cjs} exist at root or under
|
|
395
|
+
# test/ or tests/. A FAILING run records fail (never swallowed); absence of
|
|
396
|
+
# node or test files falls through to the honest "skipped" path below.
|
|
397
|
+
if [ "$runner" = "none" ] && command -v node >/dev/null 2>&1; then
|
|
398
|
+
local _nt_files=()
|
|
399
|
+
local _nt_f _nt_dir
|
|
400
|
+
while IFS= read -r _nt_f; do
|
|
401
|
+
[ -n "$_nt_f" ] && _nt_files+=("$_nt_f")
|
|
402
|
+
done < <(find "$tree" -maxdepth 1 -type f \
|
|
403
|
+
\( -name '*.test.js' -o -name '*.test.mjs' -o -name '*.test.cjs' \) \
|
|
404
|
+
2>/dev/null)
|
|
405
|
+
for _nt_dir in test tests; do
|
|
406
|
+
[ -d "$tree/$_nt_dir" ] || continue
|
|
407
|
+
while IFS= read -r _nt_f; do
|
|
408
|
+
[ -n "$_nt_f" ] && _nt_files+=("$_nt_f")
|
|
409
|
+
done < <(find "$tree/$_nt_dir" -maxdepth 3 -type f \
|
|
410
|
+
\( -name '*.test.js' -o -name '*.test.mjs' -o -name '*.test.cjs' \) \
|
|
411
|
+
-not -path '*/node_modules/*' 2>/dev/null)
|
|
412
|
+
done
|
|
413
|
+
if [ "${#_nt_files[@]}" -gt 0 ]; then
|
|
414
|
+
runner="node-test"
|
|
415
|
+
# Pass matched files explicitly (node --test globbing is
|
|
416
|
+
# Node-version-sensitive); quote each so paths with spaces survive.
|
|
417
|
+
out="$(cd "$tree" && timeout "$timeout_s" node --test "${_nt_files[@]}" 2>&1)" || rc=$?
|
|
418
|
+
fi
|
|
419
|
+
fi
|
|
420
|
+
|
|
321
421
|
if [ "$runner" = "none" ]; then
|
|
322
422
|
_verify_add_gate "tests" "skipped" "" "no test framework detected" "true"
|
|
323
423
|
return 0
|
|
@@ -916,6 +1016,100 @@ PYEOF
|
|
|
916
1016
|
return 0
|
|
917
1017
|
}
|
|
918
1018
|
|
|
1019
|
+
# ---------------------------------------------------------------------------
|
|
1020
|
+
# Setup-recipe WRITER (rank 7). Writes .loki/setup-recipe.json after a VERIFIED
|
|
1021
|
+
# runtime boot, capturing the deterministic steps to reach a runnable state so
|
|
1022
|
+
# later verify/e2e runs replay the recipe instead of re-detecting heuristically
|
|
1023
|
+
# (the CONSUMER already exists in _verify_runtime_detect: it reads "start"/"port").
|
|
1024
|
+
#
|
|
1025
|
+
# Keys: {install, seed, env_keys, start, health_path, port}.
|
|
1026
|
+
# - env_keys are env var NAMES ONLY, harvested from .env.example / .env in the
|
|
1027
|
+
# tree (left side of `NAME=value`). SECRET VALUES ARE NEVER PERSISTED.
|
|
1028
|
+
# - install/seed are best-effort deterministic detections (npm ci / pip install
|
|
1029
|
+
# / go mod download; a seed script when present). Empty string when unknown.
|
|
1030
|
+
#
|
|
1031
|
+
# Best effort and fail-open: any failure leaves no recipe and never changes the
|
|
1032
|
+
# gate outcome. Called ONLY from the boot-pass branch, so a failed boot writes
|
|
1033
|
+
# nothing.
|
|
1034
|
+
#
|
|
1035
|
+
# Args: $1 tree $2 start_command $3 port $4 health_path
|
|
1036
|
+
# ---------------------------------------------------------------------------
|
|
1037
|
+
_verify_write_setup_recipe() {
|
|
1038
|
+
local tree="$1" start="$2" port="$3" health_path="$4"
|
|
1039
|
+
command -v python3 >/dev/null 2>&1 || return 0
|
|
1040
|
+
[ -d "$tree" ] || return 0
|
|
1041
|
+
|
|
1042
|
+
# Detect a deterministic install command from the tree (best effort).
|
|
1043
|
+
local install=""
|
|
1044
|
+
if [ -f "$tree/package-lock.json" ]; then
|
|
1045
|
+
install="npm ci"
|
|
1046
|
+
elif [ -f "$tree/package.json" ]; then
|
|
1047
|
+
install="npm install"
|
|
1048
|
+
elif [ -f "$tree/requirements.txt" ]; then
|
|
1049
|
+
install="pip install -r requirements.txt"
|
|
1050
|
+
elif [ -f "$tree/go.mod" ]; then
|
|
1051
|
+
install="go mod download"
|
|
1052
|
+
elif [ -f "$tree/Cargo.toml" ]; then
|
|
1053
|
+
install="cargo fetch"
|
|
1054
|
+
fi
|
|
1055
|
+
|
|
1056
|
+
# Detect a seed step (best effort): package.json "seed" script, else empty.
|
|
1057
|
+
local seed=""
|
|
1058
|
+
if [ -f "$tree/package.json" ] && grep -q '"seed"[[:space:]]*:' "$tree/package.json" 2>/dev/null; then
|
|
1059
|
+
seed="npm run seed"
|
|
1060
|
+
fi
|
|
1061
|
+
|
|
1062
|
+
# Harvest env var NAMES only from .env.example / .env (never the values).
|
|
1063
|
+
# The python writer parses these; we hand it the file paths that exist.
|
|
1064
|
+
local env_src=""
|
|
1065
|
+
[ -f "$tree/.env.example" ] && env_src="$tree/.env.example"
|
|
1066
|
+
[ -z "$env_src" ] && [ -f "$tree/.env" ] && env_src="$tree/.env"
|
|
1067
|
+
|
|
1068
|
+
_SR_DIR="$tree/.loki" _SR_INSTALL="$install" _SR_SEED="$seed" \
|
|
1069
|
+
_SR_START="$start" _SR_HEALTH="$health_path" _SR_PORT="$port" \
|
|
1070
|
+
_SR_ENVSRC="$env_src" \
|
|
1071
|
+
python3 - <<'PYEOF' 2>/dev/null || return 0
|
|
1072
|
+
import json, os, re
|
|
1073
|
+
|
|
1074
|
+
out_dir = os.environ["_SR_DIR"]
|
|
1075
|
+
|
|
1076
|
+
# env_keys: NAMES ONLY. Parse KEY=VALUE / export KEY=VALUE lines, keep the KEY.
|
|
1077
|
+
# The value side is never read into the recipe (security AC).
|
|
1078
|
+
env_keys = []
|
|
1079
|
+
src = os.environ.get("_SR_ENVSRC") or ""
|
|
1080
|
+
if src and os.path.exists(src):
|
|
1081
|
+
seen = set()
|
|
1082
|
+
try:
|
|
1083
|
+
with open(src) as f:
|
|
1084
|
+
for line in f:
|
|
1085
|
+
line = line.strip()
|
|
1086
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
1087
|
+
continue
|
|
1088
|
+
name = line.split("=", 1)[0].strip()
|
|
1089
|
+
name = re.sub(r"^export\s+", "", name)
|
|
1090
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) and name not in seen:
|
|
1091
|
+
seen.add(name)
|
|
1092
|
+
env_keys.append(name)
|
|
1093
|
+
except Exception:
|
|
1094
|
+
env_keys = []
|
|
1095
|
+
|
|
1096
|
+
recipe = {
|
|
1097
|
+
"install": os.environ.get("_SR_INSTALL", ""),
|
|
1098
|
+
"seed": os.environ.get("_SR_SEED", ""),
|
|
1099
|
+
"env_keys": env_keys,
|
|
1100
|
+
"start": os.environ.get("_SR_START", ""),
|
|
1101
|
+
"health_path": os.environ.get("_SR_HEALTH", "") or "/",
|
|
1102
|
+
"port": os.environ.get("_SR_PORT", ""),
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
1106
|
+
with open(os.path.join(out_dir, "setup-recipe.json"), "w") as f:
|
|
1107
|
+
json.dump(recipe, f, indent=2)
|
|
1108
|
+
f.write("\n")
|
|
1109
|
+
PYEOF
|
|
1110
|
+
return 0
|
|
1111
|
+
}
|
|
1112
|
+
|
|
919
1113
|
verify_gate_runtime() {
|
|
920
1114
|
local tree="$1"
|
|
921
1115
|
# Opt-out (default on). Disabled -> emit no row -> byte-identical.
|
|
@@ -1055,6 +1249,11 @@ verify_gate_runtime() {
|
|
|
1055
1249
|
"Runtime boot smoke failed: '$method' booted but returned HTTP $http_status on $url (server error)."
|
|
1056
1250
|
else
|
|
1057
1251
|
_verify_add_gate "runtime" "pass" "boot" "$summary" "true"
|
|
1252
|
+
# Setup-recipe WRITER (rank 7). Only on a VERIFIED boot (2xx/3xx/4xx):
|
|
1253
|
+
# persist a deterministic .loki/setup-recipe.json that the runtime
|
|
1254
|
+
# detector (_verify_runtime_detect) consumes first on later runs. Env var
|
|
1255
|
+
# NAMES only, never secret VALUES. Best effort; never changes the verdict.
|
|
1256
|
+
_verify_write_setup_recipe "$tree" "$method" "$probe_port" "$health_path" || true
|
|
1058
1257
|
fi
|
|
1059
1258
|
|
|
1060
1259
|
# Record a structured runtime artifact alongside the gate row so the boot is
|
|
@@ -1339,6 +1538,13 @@ verify_emit_evidence() {
|
|
|
1339
1538
|
_V_BLOCKON="$block_on" \
|
|
1340
1539
|
_V_LEDGER_SHA="${_VERIFY_EXPECT_LEDGER_SHA:-}" \
|
|
1341
1540
|
_V_LEDGER_HASHOK="${_VERIFY_EXPECT_LEDGER_HASH_OK:-}" \
|
|
1541
|
+
_V_SCOPE_FILES="${VERIFY_SCOPE_FILES:-}" \
|
|
1542
|
+
_V_SCOPE_NET="${VERIFY_SCOPE_NET_LINES:-}" \
|
|
1543
|
+
_V_SCOPE_VERDICT="${VERIFY_SCOPE_VERDICT:-}" \
|
|
1544
|
+
_V_SCOPE_SOFT_FILES="${VERIFY_SCOPE_SOFT_FILES:-}" \
|
|
1545
|
+
_V_SCOPE_SOFT_NET="${VERIFY_SCOPE_SOFT_NET_LINES:-}" \
|
|
1546
|
+
_V_SCOPE_MAX_FILES="${VERIFY_SCOPE_MAX_FILES:-}" \
|
|
1547
|
+
_V_SCOPE_MAX_NET="${VERIFY_SCOPE_MAX_NET_LINES:-}" \
|
|
1342
1548
|
python3 - <<'PYEOF'
|
|
1343
1549
|
import json, os, hashlib
|
|
1344
1550
|
|
|
@@ -1432,6 +1638,33 @@ if _ledger_sha:
|
|
|
1432
1638
|
"hash_ok": (os.environ.get("_V_LEDGER_HASHOK") == "true"),
|
|
1433
1639
|
}
|
|
1434
1640
|
|
|
1641
|
+
# Code-scope / locality record (rank 10, advisory-first). Additive top-level
|
|
1642
|
+
# "scope" object; the verdict here is a LABEL only and never affects the
|
|
1643
|
+
# document's authoritative verdict/exit_code. Emitted only when the scope helper
|
|
1644
|
+
# ran (env set), so evidence.json stays byte-identical on paths that skip it.
|
|
1645
|
+
_scope_verdict = os.environ.get("_V_SCOPE_VERDICT") or ""
|
|
1646
|
+
if _scope_verdict:
|
|
1647
|
+
def _to_int(name, default=0):
|
|
1648
|
+
v = os.environ.get(name) or ""
|
|
1649
|
+
try:
|
|
1650
|
+
return int(v)
|
|
1651
|
+
except (TypeError, ValueError):
|
|
1652
|
+
return default
|
|
1653
|
+
_thresholds = {
|
|
1654
|
+
"soft_files": _to_int("_V_SCOPE_SOFT_FILES"),
|
|
1655
|
+
"soft_net_lines": _to_int("_V_SCOPE_SOFT_NET"),
|
|
1656
|
+
# Hard caps are opt-in; None when not explicitly set.
|
|
1657
|
+
"max_files": (_to_int("_V_SCOPE_MAX_FILES") if (os.environ.get("_V_SCOPE_MAX_FILES") or "") != "" else None),
|
|
1658
|
+
"max_net_lines": (_to_int("_V_SCOPE_MAX_NET") if (os.environ.get("_V_SCOPE_MAX_NET") or "") != "" else None),
|
|
1659
|
+
}
|
|
1660
|
+
doc["scope"] = {
|
|
1661
|
+
"files_changed": _to_int("_V_SCOPE_FILES"),
|
|
1662
|
+
"net_lines": _to_int("_V_SCOPE_NET"),
|
|
1663
|
+
"verdict": _scope_verdict,
|
|
1664
|
+
"advisory": True,
|
|
1665
|
+
"thresholds": _thresholds,
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1435
1668
|
os.makedirs(out_dir, exist_ok=True)
|
|
1436
1669
|
ev_path = os.path.join(out_dir, "evidence.json")
|
|
1437
1670
|
with open(ev_path, "w") as f:
|
|
@@ -2069,6 +2302,11 @@ verify_main() {
|
|
|
2069
2302
|
|
|
2070
2303
|
verify_diff_base "$base_ref"
|
|
2071
2304
|
|
|
2305
|
+
# Code-scope / locality record (rank 10, advisory-first). Reads the diff
|
|
2306
|
+
# stats verify_diff_base just computed; never emits a gate row or feeds the
|
|
2307
|
+
# verdict, so it can never block or change the exit code.
|
|
2308
|
+
verify_scope_record
|
|
2309
|
+
|
|
2072
2310
|
# Short-circuit on an empty or unresolvable change set. A verifier verifies
|
|
2073
2311
|
# a CHANGE; with nothing to verify there is no basis for a VERIFIED verdict.
|
|
2074
2312
|
# Recording the gates as skipped (rather than running them against the whole
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
|
|
4
4
|
|
|
5
|
-
**Version:** v7.
|
|
5
|
+
**Version:** v7.116.0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var q8=Object.defineProperty;var J8=($)=>$;function V8($,Q){this[$]=J8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)q8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:V8.bind(Q,Z)})};var P=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var m1={};b(m1,{lokiDir:()=>j,homeLokiDir:()=>T$,findRepoRootForVersion:()=>$1,REPO_ROOT:()=>h});import{resolve as t,dirname as e$}from"path";import{fileURLToPath as W8}from"url";import{existsSync as N$}from"fs";import{homedir as H8}from"os";function G8(){let $=v1;for(let Q=0;Q<6;Q++){if(N$(t($,"VERSION"))&&N$(t($,"autonomy/run.sh")))return $;let Z=e$($);if(Z===$)break;$=Z}return t(v1,"..","..","..")}function $1($){let Q=$;for(let Z=0;Z<6;Z++){if(N$(t(Q,"VERSION"))&&N$(t(Q,"autonomy/run.sh")))return Q;let z=e$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function j(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(H8(),".loki")}var v1,h;var C=P(()=>{v1=e$(W8(import.meta.url));h=G8()});import{readFileSync as U8}from"fs";import{resolve as Y8,dirname as B8}from"path";import{fileURLToPath as M8}from"url";function S$(){if(Q$!==null)return Q$;let $="7.
|
|
2
|
+
var q8=Object.defineProperty;var J8=($)=>$;function V8($,Q){this[$]=J8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)q8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:V8.bind(Q,Z)})};var P=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var m1={};b(m1,{lokiDir:()=>j,homeLokiDir:()=>T$,findRepoRootForVersion:()=>$1,REPO_ROOT:()=>h});import{resolve as t,dirname as e$}from"path";import{fileURLToPath as W8}from"url";import{existsSync as N$}from"fs";import{homedir as H8}from"os";function G8(){let $=v1;for(let Q=0;Q<6;Q++){if(N$(t($,"VERSION"))&&N$(t($,"autonomy/run.sh")))return $;let Z=e$($);if(Z===$)break;$=Z}return t(v1,"..","..","..")}function $1($){let Q=$;for(let Z=0;Z<6;Z++){if(N$(t(Q,"VERSION"))&&N$(t(Q,"autonomy/run.sh")))return Q;let z=e$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function j(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(H8(),".loki")}var v1,h;var C=P(()=>{v1=e$(W8(import.meta.url));h=G8()});import{readFileSync as U8}from"fs";import{resolve as Y8,dirname as B8}from"path";import{fileURLToPath as M8}from"url";function S$(){if(Q$!==null)return Q$;let $="7.116.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=B8(M8(import.meta.url)),Z=$1(Q);Q$=U8(Y8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var Q1=P(()=>{C()});var p1={};b(p1,{runOrThrow:()=>S8,run:()=>R,readStreamCapped:()=>c1,commandVersion:()=>C8,commandExists:()=>u,ShellError:()=>Z1,MAX_STDOUT_BYTES:()=>u1});async function c1($,Q=u1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function R($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([c1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function S8($,Q={}){let Z=await R($,Q);if(Z.exitCode!==0)throw new Z1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function u($){let Q=D8($),Z=await R(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function D8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function C8($,Q="--version"){if(!await u($))return null;let z=await R([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var u1=16777216,Z1;var o=P(()=>{Z1=class Z1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function r($){return b8?"":$}var b8,M,S,_,tZ,L,k,y,J;var p=P(()=>{b8=(process.env.NO_COLOR??"").length>0;M=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),tZ=r("\x1B[0;34m"),L=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as l8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(l8($))return A$=$,$;let Q=await u("python3.12");if(Q)return A$=Q,Q;let Z=await u("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return R([Z,"-c",$],Q)}var A$;var V$=P(()=>{o()});var V0={};b(V0,{runStatus:()=>U3});import{existsSync as v,readFileSync as H$,readdirSync as $0,statSync as Q0}from"fs";import{resolve as D,basename as z3}from"path";import{homedir as X3}from"os";function Z0($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function z0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*C$/Q);if(X>C$)X=C$;let q=C$-X,K=S;if(z>=80)K=M;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=Z0($),H=Z0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${H})`}async function q3(){if(await u("jq"))return!0;return process.stdout.write(`${M}Error: jq is required but not installed.${J}
|
|
3
3
|
`),process.stdout.write(`Install with:
|
|
4
4
|
`),process.stdout.write(` brew install jq (macOS)
|
|
5
5
|
`),process.stdout.write(` apt install jq (Debian/Ubuntu)
|
|
@@ -814,4 +814,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
814
814
|
`),2}default:return process.stderr.write(`Unknown command: ${Q}
|
|
815
815
|
`),process.stderr.write(K8),2}}e1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var SZ=await NZ(Bun.argv.slice(2));process.exit(SZ);
|
|
816
816
|
|
|
817
|
-
//# debugId=
|
|
817
|
+
//# debugId=30F27BA7342E3CAE64756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
3
|
"mcpName": "io.github.asklokesh/loki-mode",
|
|
4
|
-
"version": "7.
|
|
4
|
+
"version": "7.116.0",
|
|
5
5
|
"description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "loki-mode",
|
|
4
4
|
"displayName": "Loki Mode",
|
|
5
|
-
"version": "7.
|
|
5
|
+
"version": "7.116.0",
|
|
6
6
|
"description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Autonomi",
|