loki-mode 7.121.2 → 7.121.4

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.
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python3
2
+ """functional-verify.py -- FV-1: does the built app actually DO what the spec asked?
3
+
4
+ The completion verifier proves code was written + tests/build passed. It does NOT
5
+ prove the spec's BEHAVIORS work: a "waitlist" spec can be built as a static page
6
+ that captures nothing, yet read VERIFIED (measured: ~half of backend-implying
7
+ specs). This harness closes that gap by EXERCISING the running app against
8
+ assertions DERIVED FROM THE SPEC, and reporting an honest functional signal.
9
+
10
+ SCOPE (FV-1, deliberately narrow + honest):
11
+ - It reports a DESCRIPTIVE signal only. It does NOT feed the VERIFIED headline
12
+ (that is FV-2: a founder-gated trust-semantics decision, council-reviewed).
13
+ - It derives HTTP endpoint assertions from the spec (GET/POST/DELETE <path>) and
14
+ runs the CRUD lifecycle they imply against the LIVE app. It never fabricates:
15
+ an endpoint it cannot reach is `inconclusive`, a behavior that failed is
16
+ `failed`, a behavior proven is `passed`. Same inconclusive-never-false moat as
17
+ the rest of the trust layer.
18
+ - It requires the app to already be running (URL passed in) -- app-runner (>=
19
+ v7.121.3, static sites included) starts it; this harness only probes.
20
+
21
+ Output: JSON to stdout -- {spec_behaviors: [...], summary: {passed, failed,
22
+ inconclusive}, functional_status: verified|partial|failed|inconclusive}.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import re
28
+ import sys
29
+ import urllib.error
30
+ import urllib.request
31
+
32
+
33
+ # --- spec -> behavioral assertions ------------------------------------------
34
+
35
+ # Endpoint declarations in a spec/PRD: "GET /api/tasks", "POST /api/tasks",
36
+ # "DELETE /api/tasks/:id". Method + path; path may carry a :param placeholder.
37
+ _ENDPOINT_RE = re.compile(
38
+ r'\b(GET|POST|PUT|PATCH|DELETE)\s+(/[A-Za-z0-9_./:{}-]*)',
39
+ )
40
+
41
+
42
+ def _clean_path(path: str) -> str:
43
+ """Strip trailing sentence punctuation a spec sentence leaves on a path
44
+ (e.g. "DELETE /api/tasks/:id." -> "/api/tasks/:id"). Without this, a probe
45
+ hits a wrong URL and fabricates a failure on a working app -- a fake-RED in
46
+ the FV layer itself, exactly what this harness exists to prevent."""
47
+ return path.rstrip(".,;:)]}") or path
48
+
49
+
50
+ def derive_endpoint_assertions(spec_text: str) -> list[dict]:
51
+ """Extract {method, path} endpoint assertions the spec explicitly names.
52
+
53
+ Deduped, order-preserved. A path with a :param / {param} is a resource route
54
+ (used for the DELETE-a-created-resource lifecycle below)."""
55
+ seen = set()
56
+ out = []
57
+ for m in _ENDPOINT_RE.finditer(spec_text or ""):
58
+ method = m.group(1).upper()
59
+ path = _clean_path(m.group(2))
60
+ key = (method, path)
61
+ if key in seen:
62
+ continue
63
+ seen.add(key)
64
+ out.append({"method": method, "path": path,
65
+ "is_resource": bool(re.search(r'[:{]', path))})
66
+ return out
67
+
68
+
69
+ # --- run assertions against the live app ------------------------------------
70
+
71
+ def _req(base_url: str, method: str, path: str, body=None, timeout=8):
72
+ """One HTTP call. Returns (status_code, json_or_text) or (None, error_str)."""
73
+ url = base_url.rstrip("/") + path
74
+ data = None
75
+ headers = {}
76
+ if body is not None:
77
+ data = json.dumps(body).encode()
78
+ headers["Content-Type"] = "application/json"
79
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
80
+ try:
81
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
82
+ raw = resp.read().decode("utf-8", "replace")
83
+ try:
84
+ return resp.status, json.loads(raw) if raw else None
85
+ except json.JSONDecodeError:
86
+ return resp.status, raw
87
+ except urllib.error.HTTPError as e:
88
+ return e.code, None
89
+ except (urllib.error.URLError, OSError) as e:
90
+ return None, str(e)
91
+
92
+
93
+ def verify_crud_lifecycle(base_url: str, assertions: list[dict]) -> list[dict]:
94
+ """The core functional check: for a REST resource the spec names, prove the
95
+ CRUD LIFECYCLE actually works end to end (not just that a route responds):
96
+ POST creates -> the created item appears on GET -> DELETE removes it ->
97
+ it is gone from a subsequent GET.
98
+ This is what distinguishes a working backend from a static shell: a static
99
+ page returns 200 on GET but a POST does not persist. Each behavior is
100
+ passed / failed / inconclusive, never fabricated."""
101
+ results = []
102
+
103
+ # Collection GET/POST endpoints (non-resource) and their resource DELETE.
104
+ gets = [a for a in assertions if a["method"] == "GET" and not a["is_resource"]]
105
+ posts = [a for a in assertions if a["method"] == "POST" and not a["is_resource"]]
106
+ deletes = [a for a in assertions if a["method"] == "DELETE" and a["is_resource"]]
107
+
108
+ # 1. Every named collection GET must actually respond 2xx.
109
+ for a in gets:
110
+ code, payload = _req(base_url, "GET", a["path"])
111
+ if code is None:
112
+ results.append({"behavior": f"GET {a['path']} responds",
113
+ "status": "inconclusive", "detail": f"unreachable: {payload}"})
114
+ elif 200 <= code < 300:
115
+ results.append({"behavior": f"GET {a['path']} responds",
116
+ "status": "passed", "detail": f"HTTP {code}"})
117
+ else:
118
+ results.append({"behavior": f"GET {a['path']} responds",
119
+ "status": "failed", "detail": f"HTTP {code}"})
120
+
121
+ # 2. POST-creates-and-persists lifecycle: for each collection POST that has a
122
+ # matching collection GET, POST an item and assert the collection grew.
123
+ for p in posts:
124
+ coll = p["path"]
125
+ matching_get = next((g for g in gets if g["path"] == coll), None)
126
+ if matching_get is None:
127
+ results.append({"behavior": f"POST {coll} persists (verifiable)",
128
+ "status": "inconclusive",
129
+ "detail": "no matching collection GET to confirm persistence"})
130
+ continue
131
+ before_code, before = _req(base_url, "GET", coll)
132
+ before_n = len(before) if isinstance(before, list) else None
133
+ # A minimal, generic payload. Real apps validate; we send a common shape.
134
+ post_code, created = _req(base_url, "POST", coll,
135
+ body={"title": "fv-probe", "name": "fv-probe",
136
+ "text": "fv-probe"})
137
+ after_code, after = _req(base_url, "GET", coll)
138
+ after_n = len(after) if isinstance(after, list) else None
139
+ if post_code is None or after_code is None:
140
+ results.append({"behavior": f"POST {coll} persists",
141
+ "status": "inconclusive",
142
+ "detail": "app unreachable during lifecycle"})
143
+ elif post_code and 200 <= post_code < 300 and before_n is not None \
144
+ and after_n is not None and after_n > before_n:
145
+ results.append({"behavior": f"POST {coll} persists",
146
+ "status": "passed",
147
+ "detail": f"collection grew {before_n} -> {after_n} after POST"})
148
+ elif post_code and 200 <= post_code < 300 and (before_n is None or after_n is None):
149
+ # POST accepted but the collection is not a JSON array we can count:
150
+ # we cannot PROVE persistence -> honest inconclusive, never a pass.
151
+ results.append({"behavior": f"POST {coll} persists",
152
+ "status": "inconclusive",
153
+ "detail": f"POST HTTP {post_code} but GET is not a countable list"})
154
+ elif post_code in (400, 422):
155
+ # The endpoint EXISTS and rejected OUR probe payload (validation).
156
+ # That is a working backend with a schema our generic {title,name,text}
157
+ # did not satisfy -- NOT proof the behavior is broken. Marking it failed
158
+ # would be a fake-RED (a strict-but-working app read as non-working).
159
+ # Honest inconclusive; FV-2 must never wire a failed verdict off this.
160
+ results.append({"behavior": f"POST {coll} persists",
161
+ "status": "inconclusive",
162
+ "detail": f"POST HTTP {post_code}: endpoint exists but rejected the "
163
+ "probe payload (schema mismatch, not proof of no-persistence)"})
164
+ else:
165
+ # POST 404 (endpoint absent), 501 (not implemented), or 2xx-but-the-
166
+ # collection did NOT grow -> the behavior the spec implies does NOT
167
+ # work (a static shell or a non-persisting backend). Genuine failed.
168
+ results.append({"behavior": f"POST {coll} persists",
169
+ "status": "failed",
170
+ "detail": f"POST HTTP {post_code}; collection {before_n} -> {after_n} "
171
+ "(no persistence -- a static shell or non-working backend)"})
172
+
173
+ # 3. DELETE lifecycle: if the spec names a resource DELETE and we created
174
+ # an item with an id, delete it and assert it's gone.
175
+ created_id = None
176
+ if isinstance(created, dict):
177
+ created_id = created.get("id") or created.get("_id")
178
+ if deletes and created_id is not None:
179
+ dpath_tmpl = deletes[0]["path"]
180
+ dpath = re.sub(r'[:{][A-Za-z0-9_]+[}]?', str(created_id), dpath_tmpl)
181
+ del_code, _ = _req(base_url, "DELETE", dpath)
182
+ _, after_del = _req(base_url, "GET", coll)
183
+ still_there = isinstance(after_del, list) and any(
184
+ isinstance(x, dict) and (x.get("id") == created_id or x.get("_id") == created_id)
185
+ for x in after_del)
186
+ if del_code is None:
187
+ results.append({"behavior": f"DELETE {dpath_tmpl} removes",
188
+ "status": "inconclusive", "detail": "unreachable"})
189
+ elif 200 <= del_code < 300 and not still_there:
190
+ results.append({"behavior": f"DELETE {dpath_tmpl} removes",
191
+ "status": "passed",
192
+ "detail": f"item {created_id} gone after DELETE (HTTP {del_code})"})
193
+ else:
194
+ results.append({"behavior": f"DELETE {dpath_tmpl} removes",
195
+ "status": "failed",
196
+ "detail": f"DELETE HTTP {del_code}; item still present={still_there}"})
197
+ return results
198
+
199
+
200
+ def summarize(results: list[dict]) -> dict:
201
+ passed = sum(1 for r in results if r["status"] == "passed")
202
+ failed = sum(1 for r in results if r["status"] == "failed")
203
+ inconclusive = sum(1 for r in results if r["status"] == "inconclusive")
204
+ # functional_status: verified only if >=1 behavior passed and NONE failed;
205
+ # failed if any behavior failed; inconclusive if nothing could be exercised.
206
+ if failed > 0:
207
+ status = "failed"
208
+ elif passed > 0 and inconclusive == 0:
209
+ status = "verified"
210
+ elif passed > 0:
211
+ status = "partial"
212
+ else:
213
+ status = "inconclusive"
214
+ return {"passed": passed, "failed": failed, "inconclusive": inconclusive,
215
+ "functional_status": status}
216
+
217
+
218
+ def run(spec_text: str, base_url: str) -> dict:
219
+ assertions = derive_endpoint_assertions(spec_text)
220
+ if not assertions:
221
+ return {"spec_behaviors": [], "summary": {"passed": 0, "failed": 0,
222
+ "inconclusive": 0, "functional_status": "inconclusive"},
223
+ "note": "no HTTP endpoint behaviors derivable from the spec"}
224
+ results = verify_crud_lifecycle(base_url, assertions)
225
+ summ = summarize(results)
226
+ return {"assertions_derived": assertions, "spec_behaviors": results,
227
+ "summary": summ, "functional_status": summ["functional_status"]}
228
+
229
+
230
+ def main(argv):
231
+ if len(argv) < 3:
232
+ print("usage: functional-verify.py <spec_file> <base_url>", file=sys.stderr)
233
+ return 2
234
+ spec_file, base_url = argv[1], argv[2]
235
+ try:
236
+ with open(spec_file, errors="replace") as f:
237
+ spec_text = f.read()
238
+ except OSError as e:
239
+ print(json.dumps({"error": f"cannot read spec: {e}"}))
240
+ return 1
241
+ print(json.dumps(run(spec_text, base_url), indent=2))
242
+ return 0
243
+
244
+
245
+ if __name__ == "__main__":
246
+ sys.exit(main(sys.argv))
@@ -287,9 +287,22 @@ def _collect_quality_gates(loki_dir):
287
287
  elif os.path.exists(result_json):
288
288
  rj = _read_json(result_json, default=None)
289
289
  if isinstance(rj, dict):
290
- status = _norm_gate_status(
291
- rj.get("passed", rj.get("status", "not_run"))
292
- )
290
+ # Read the marker's outcome key. enforce_static_analysis writes
291
+ # `"pass"` (a bool); other markers may write `"passed"` or
292
+ # `"status"`. Try all three so a real result is NEVER misread as
293
+ # not_run: a failing static-analysis marker ({"pass":false,
294
+ # "findings":11}) was collapsing to not_run (the reader looked
295
+ # only for "passed"/"status"), understating a real gate FAILURE
296
+ # as "did not run" -- the receipt read "gaps" where it should
297
+ # read a failed gate. _norm_gate_status maps a bool correctly
298
+ # (True->passed, False->failed). A key that is genuinely absent
299
+ # still defaults to not_run (honest -- never fabricated passed).
300
+ if "pass" in rj:
301
+ status = _norm_gate_status(rj.get("pass"))
302
+ else:
303
+ status = _norm_gate_status(
304
+ rj.get("passed", rj.get("status", "not_run"))
305
+ )
293
306
  if status is not None:
294
307
  gates.append({"name": gate_name, "status": status})
295
308
  seen.add(gate_name)
@@ -431,6 +444,64 @@ def _norm_tests_status(raw):
431
444
  return s
432
445
 
433
446
 
447
+ def _collect_functional(loki_dir):
448
+ """Read .loki/quality/functional-results.json (the FV-1 functional harness).
449
+
450
+ Deterministic FACT: did the built app actually DO what the spec asked (run the
451
+ app + exercise spec-derived behaviors -- POST persists, GET reflects, ...), as
452
+ opposed to just compiling and passing unit tests. Tolerates an absent file ->
453
+ status not_run. Shape mirrors the FV-1 harness output:
454
+ {ran, functional_status, passed, failed, inconclusive}.
455
+
456
+ DESCRIPTIVE ONLY (FV-2, record half): this fact is RECORDED on the receipt but
457
+ is deliberately NOT read by _compute_headline / _compute_degraded, so it does
458
+ NOT change what "Verified" means. Making functional-satisfaction gate the green
459
+ headline is a trust-semantics product decision (council + founder), the second
460
+ half of FV-2. Recording it first lets the signal be seen and validated safely.
461
+ """
462
+ out = {"ran": False, "functional_status": "not_run",
463
+ "passed": 0, "failed": 0, "inconclusive": 0}
464
+ raw = _read_json(
465
+ os.path.join(loki_dir, "quality", "functional-results.json"), default=None
466
+ )
467
+ if not isinstance(raw, dict):
468
+ return out
469
+ out["ran"] = True
470
+ out["functional_status"] = str(raw.get("functional_status") or "inconclusive")
471
+ summary = raw.get("summary") if isinstance(raw.get("summary"), dict) else raw
472
+ for k in ("passed", "failed", "inconclusive"):
473
+ v = summary.get(k)
474
+ if isinstance(v, int):
475
+ out[k] = v
476
+ return out
477
+
478
+
479
+ def _collect_healthcheck(loki_dir):
480
+ """Read .loki/app-runner/health.json (the app-runner liveness probe).
481
+
482
+ Deterministic FACT: did the built app actually come up and respond (HTTP/PID
483
+ health), as written by app-runner. Absent -> not_run. Shape:
484
+ {ran, ok, status, checked_at}. status: not_run (never checked) | healthy
485
+ (ran, ok:true) | unhealthy (ran, ok:false).
486
+
487
+ DESCRIPTIVE ONLY (Evidence Receipt record half): recorded for transparency,
488
+ NOT read by _compute_headline / _compute_degraded, so it does not change what
489
+ "Verified" means. Gating on it is the founder-gated trust decision (mirrors the
490
+ FV-2 opt-in gate). ponytail: reuses the health.json app-runner already writes.
491
+ """
492
+ out = {"ran": False, "ok": False, "status": "not_run", "checked_at": ""}
493
+ raw = _read_json(
494
+ os.path.join(loki_dir, "app-runner", "health.json"), default=None
495
+ )
496
+ if not isinstance(raw, dict):
497
+ return out
498
+ out["ran"] = True
499
+ out["ok"] = bool(raw.get("ok"))
500
+ out["checked_at"] = str(raw.get("checked_at") or "")
501
+ out["status"] = "healthy" if out["ok"] else "unhealthy"
502
+ return out
503
+
504
+
434
505
  def _collect_tests(loki_dir):
435
506
  """Read .loki/quality/test-results.json.
436
507
 
@@ -763,6 +834,8 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
763
834
  build = _collect_build(loki_dir)
764
835
  tests = _collect_tests(loki_dir)
765
836
  security = _collect_security(loki_dir)
837
+ functional = _collect_functional(loki_dir) # FV-2 record-half: descriptive only
838
+ healthcheck = _collect_healthcheck(loki_dir) # Evidence Receipt record-half
766
839
  evidence_gate = _collect_evidence_gate(loki_dir)
767
840
 
768
841
  deployed_url = os.environ.get("LOKI_DEPLOYED_URL") or None
@@ -800,6 +873,14 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
800
873
  for g in (quality_gates.get("gates") or [])
801
874
  ],
802
875
  "security": security,
876
+ # FV-2 (record half): did the app actually DO what the spec asked? Present
877
+ # for transparency; DELIBERATELY NOT read by _compute_headline /
878
+ # _compute_degraded, so it does not (yet) change the verdict. Wiring it into
879
+ # the green headline is the founder-gated trust-semantics decision.
880
+ "functional": functional,
881
+ # Evidence Receipt (record half): did the built app come up + respond?
882
+ # Descriptive; NOT read by _compute_headline (gating is founder-gated).
883
+ "healthcheck": healthcheck,
803
884
  "cost": cost,
804
885
  "meta": {
805
886
  "run_id": run_id,
@@ -964,12 +1045,25 @@ def _compute_headline(facts, degraded):
964
1045
  # intent). This keeps the receipt honest about security, not just tests.
965
1046
  sec = facts.get("security") or {}
966
1047
  sec_high = bool(sec.get("ran") and (sec.get("high_active") or 0) > 0)
1048
+ # FV-2 gate (opt-in via LOKI_FV_GATE=1, default OFF -> headline unchanged). When
1049
+ # enabled, a functional check that RAN and FAILED (the built app does not do what
1050
+ # the spec asked -- a static shell for a backend spec) is a hard failure, same
1051
+ # class as a failed test. Default-off keeps every existing build's verdict
1052
+ # byte-identical; the founder flips it on after reviewing the reclassification.
1053
+ # ponytail: reads the already-recorded functional fact; no new plumbing.
1054
+ fn = facts.get("functional") or {}
1055
+ fn_failed = bool(
1056
+ os.environ.get("LOKI_FV_GATE") == "1"
1057
+ and fn.get("ran")
1058
+ and fn.get("functional_status") == "failed"
1059
+ )
967
1060
  any_failed = (
968
1061
  tests.get("status") == "failed"
969
1062
  or build.get("status") == "failed"
970
1063
  or any(g.get("status") == "failed"
971
1064
  for g in (facts.get("quality_gates") or []))
972
1065
  or sec_high
1066
+ or fn_failed
973
1067
  )
974
1068
  if any_failed:
975
1069
  return "NOT VERIFIED"
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # scaffold-hook.sh -- opt-in seam that wires the contract-first scaffold (M1-M3)
3
+ # into the build lane. Mirrors the LOKI_SENTRUX_GATE pattern: sourced ONLY when
4
+ # the gate is enabled, and no-ops safely otherwise.
5
+ #
6
+ # ENABLED by LOKI_SCAFFOLD_CONTRACT_FIRST=1 (default OFF -> the default build path
7
+ # is byte-identical and completely unaffected). When ON, and ONLY for a GREENFIELD
8
+ # target (an effectively-empty project dir) whose PRD implies a web/API backend,
9
+ # it lays down the contract-first + real-backend + design-system skeleton BEFORE
10
+ # the SDLC loop, so the model builds ON a real wired substrate instead of
11
+ # hand-writing disconnected files. It NEVER touches a non-empty/brownfield repo.
12
+ #
13
+ # It is deliberately conservative: on any doubt it does nothing (returns 0) and
14
+ # lets the normal build proceed -- it can only ADD a starting skeleton, never
15
+ # remove or overwrite existing code.
16
+ #
17
+ # PARITY: this is a bash-only ORCHESTRATION seam in run_autonomous, NOT part of
18
+ # the byte-mirrored build_prompt() contract (build_prompt.ts is unchanged). It
19
+ # mirrors the LOKI_SENTRUX_GATE precedent, which is likewise bash-orchestration
20
+ # only (not behavior-mirrored in the bun runner). Because it is DEFAULT-OFF, the
21
+ # bun default path is unaffected. When the bun runner (loki-ts autonomous.ts,
22
+ # currently a skeleton port) reaches run_autonomous behavior parity, it should
23
+ # mirror this opt-in gate.
24
+
25
+ # Is the target dir greenfield (no meaningful source yet)? Ignores .loki, .git,
26
+ # the PRD, and dotfiles. Echoes "yes"/"no".
27
+ _scaffold_is_greenfield() {
28
+ local dir="${1:-.}"
29
+ local n
30
+ n=$(find "$dir" -type f \
31
+ -not -path "*/.loki/*" -not -path "*/.git/*" \
32
+ -not -name "PRD.md" -not -name "prd.md" -not -name ".*" \
33
+ -not -path "*/node_modules/*" 2>/dev/null | head -5 | wc -l | tr -d ' ')
34
+ [ "${n:-0}" -eq 0 ] && echo "yes" || echo "no"
35
+ }
36
+
37
+ # Derive a resource + fields from a PRD, conservatively. Only fires when the PRD
38
+ # clearly names a REST resource; otherwise echoes nothing (caller skips).
39
+ # Kept intentionally simple + general (no product-specific knowledge).
40
+ _scaffold_derive_resource() {
41
+ local prd="$1"
42
+ [ -f "$prd" ] || return 0
43
+ # Look for an explicit "GET/POST /api/<collection>" the way FV-1 does.
44
+ local coll
45
+ coll=$(grep -oiE '(GET|POST)[[:space:]]+/api/[a-z][a-z0-9_-]+' "$prd" 2>/dev/null \
46
+ | grep -oE '/api/[a-z][a-z0-9_-]+' | head -1 | sed 's#/api/##')
47
+ [ -z "$coll" ] && return 0
48
+ # singularize naively for the resource name
49
+ local res="${coll%s}"; [ -z "$res" ] && res="$coll"
50
+ echo "$res"
51
+ }
52
+
53
+ # The hook. Args: <target_dir> <prd_path>. Safe to call unconditionally; it
54
+ # self-gates on the flag + greenfield + a derivable resource.
55
+ run_contract_scaffold_hook() {
56
+ [ "${LOKI_SCAFFOLD_CONTRACT_FIRST:-0}" = "1" ] || return 0
57
+ local dir="${1:-.}" prd="${2:-}"
58
+ local script_dir
59
+ script_dir="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
60
+
61
+ [ "$(_scaffold_is_greenfield "$dir")" = "yes" ] || {
62
+ log_info "contract-scaffold: target is not greenfield -> skipping (never overwrites existing code)" 2>/dev/null || true
63
+ return 0
64
+ }
65
+ local res
66
+ res=$(_scaffold_derive_resource "$prd")
67
+ [ -z "$res" ] && {
68
+ log_info "contract-scaffold: no REST resource derivable from the PRD -> skipping" 2>/dev/null || true
69
+ return 0
70
+ }
71
+
72
+ local scaffold="${script_dir}/lib/contract-scaffold/scaffold.sh"
73
+ local generate="${script_dir}/lib/contract-scaffold/generate.mjs"
74
+ [ -f "$scaffold" ] && [ -f "$generate" ] || return 0
75
+
76
+ log_info "contract-scaffold: greenfield + resource '$res' -> laying down contract-first + backend + design skeleton" 2>/dev/null || true
77
+ bash "$scaffold" "$dir" "$res" 2>/dev/null || true
78
+ if command -v node >/dev/null 2>&1; then
79
+ node "$generate" "$dir" "$res" 2>/dev/null || true
80
+ fi
81
+ return 0
82
+ }
package/autonomy/run.sh CHANGED
@@ -8511,6 +8511,30 @@ enforce_build_check() {
8511
8511
  return 0
8512
8512
  }
8513
8513
 
8514
+ # True when the workspace's package.json declares a non-empty `lint` script, so
8515
+ # the static-analysis gate can run the app's OWN linter (oxlint/eslint/biome/...)
8516
+ # via `npm run lint` rather than only recognizing eslint config files. Pure read
8517
+ # of package.json; returns non-zero on absence / no lint script / unreadable.
8518
+ has_npm_lint_script() {
8519
+ local dir="${1:-.}"
8520
+ local pkg="$dir/package.json"
8521
+ [ -f "$pkg" ] || return 1
8522
+ # Parse with python3 (already a hard dep of the engine) so a `"lint":` inside
8523
+ # a string value or comment cannot yield a false positive; require a real,
8524
+ # non-empty scripts.lint entry.
8525
+ python3 - "$pkg" <<'PYEOF' 2>/dev/null
8526
+ import json, sys
8527
+ try:
8528
+ with open(sys.argv[1]) as fh:
8529
+ data = json.load(fh)
8530
+ except Exception:
8531
+ sys.exit(1)
8532
+ scripts = data.get("scripts") or {}
8533
+ lint = scripts.get("lint")
8534
+ sys.exit(0 if isinstance(lint, str) and lint.strip() else 1)
8535
+ PYEOF
8536
+ }
8537
+
8514
8538
  enforce_static_analysis() {
8515
8539
  local loki_dir="${TARGET_DIR:-.}/.loki"
8516
8540
  local quality_dir="$loki_dir/quality"
@@ -8539,6 +8563,31 @@ enforce_static_analysis() {
8539
8563
  done
8540
8564
  if [ -n "$abs_files" ]; then
8541
8565
  total_checked=$((total_checked + $(echo "$abs_files" | wc -w)))
8566
+ # v7.x (static-analysis honest coverage): ADDITIVELY run the app's OWN
8567
+ # declared `lint` script when present. Generated apps increasingly use
8568
+ # oxlint / biome (not eslint) -- e.g. package.json `"lint": "oxlint"`
8569
+ # with a `.oxlintrc.json` -- which the eslint-config probe below does
8570
+ # not recognize, so the app's real lint check was skipped. Running
8571
+ # `npm run lint` respects whatever linter the app declares. This is
8572
+ # ADDITIVE (not a replacement): we STILL run the eslint/tsc type checks
8573
+ # below, so a lint pass never silences the type-error check for TS apps
8574
+ # (oxlint is not type-aware). rc 127 = the declared linter is not
8575
+ # installed/resolvable -> HONEST skip, never counted as a violation
8576
+ # (rc-only signal; a real lint failure whose OUTPUT mentions "not
8577
+ # found" must still count, so we do NOT grep the message).
8578
+ if has_npm_lint_script "${TARGET_DIR:-.}"; then
8579
+ local lint_out lint_rc=0
8580
+ lint_out=$(cd "${TARGET_DIR:-.}" && npm run --silent lint 2>&1) || lint_rc=$?
8581
+ if [ "$lint_rc" -eq 127 ]; then
8582
+ log_info "Static analysis: app 'lint' script did not resolve a linter (not run, honest skip)"
8583
+ elif [ "$lint_rc" -ne 0 ]; then
8584
+ findings=$((findings + 1))
8585
+ details="${details}Lint (npm run lint): $(echo "$lint_out" | tail -3 | tr '\n' ' '). "
8586
+ fi
8587
+ fi
8588
+ # Type/syntax check path (unchanged contract): eslint when configured,
8589
+ # else tsc project-mode + per-file parse. Runs REGARDLESS of the lint
8590
+ # step above so TS type errors are always caught.
8542
8591
  if [ -f "${TARGET_DIR:-.}/.eslintrc.js" ] || [ -f "${TARGET_DIR:-.}/.eslintrc.json" ] || \
8543
8592
  [ -f "${TARGET_DIR:-.}/eslint.config.js" ] || [ -f "${TARGET_DIR:-.}/eslint.config.mjs" ]; then
8544
8593
  local eslint_out
@@ -8559,7 +8608,7 @@ enforce_static_analysis() {
8559
8608
  if [ -f "${TARGET_DIR:-.}/tsconfig.json" ] && command -v tsc &>/dev/null; then
8560
8609
  local _has_ts=0
8561
8610
  for f in $abs_files; do
8562
- case "$f" in *.ts|*.tsx) _has_ts=1; break ;; esac
8611
+ case "$f" in *.ts|*.tsx|*.jsx) _has_ts=1; break ;; esac
8563
8612
  done
8564
8613
  if [ "$_has_ts" -eq 1 ]; then
8565
8614
  _ts_project_mode=1
@@ -8569,7 +8618,7 @@ enforce_static_analysis() {
8569
8618
  local _changed_ts_errors=""
8570
8619
  for f in $js_files; do
8571
8620
  case "$f" in
8572
- *.ts|*.tsx)
8621
+ *.ts|*.tsx|*.jsx)
8573
8622
  # tsc emits paths relative to project root with `(line,col):` suffix.
8574
8623
  # v7.5.12 Dev11 (R1 MED): use grep -F (literal) so filenames
8575
8624
  # containing regex metacharacters cannot cause false positives
@@ -8591,11 +8640,16 @@ enforce_static_analysis() {
8591
8640
  fi
8592
8641
  fi
8593
8642
  for f in $abs_files; do
8594
- # node --check cannot parse TypeScript / TSX files; it
8595
- # crashes with ERR_UNKNOWN_FILE_EXTENSION. Skip them when
8596
- # tsc is not available; otherwise delegate to tsc.
8643
+ # node --check cannot parse TypeScript / TSX / JSX files; it
8644
+ # crashes with ERR_UNKNOWN_FILE_EXTENSION (JSX) or a parse error
8645
+ # (TS syntax). Route .ts/.tsx AND .jsx to the JSX-capable tsc
8646
+ # path; only plain .js/.mjs/.cjs go through node --check. Before
8647
+ # the .jsx addition here, every valid .jsx file was falsely
8648
+ # reported as a "Syntax error" (node --check ERR_UNKNOWN_FILE_
8649
+ # EXTENSION) -- a whole class of false static-analysis findings
8650
+ # on React apps.
8597
8651
  case "$f" in
8598
- *.ts|*.tsx)
8652
+ *.ts|*.tsx|*.jsx)
8599
8653
  # When tsconfig project-mode handled it above, skip
8600
8654
  # the per-file fallback to avoid duplicate / false errors.
8601
8655
  if [ "$_ts_project_mode" -eq 1 ]; then
@@ -16254,6 +16308,21 @@ run_autonomous() {
16254
16308
  esac
16255
16309
  fi
16256
16310
 
16311
+ # Contract-first scaffold seam (opt-in via LOKI_SCAFFOLD_CONTRACT_FIRST=1,
16312
+ # default OFF -> default build path is byte-identical). Mirrors the
16313
+ # LOKI_SENTRUX_GATE pattern: source the helper only when the gate is enabled,
16314
+ # and it self-gates further on greenfield + a derivable REST resource. It can
16315
+ # ONLY add a starting skeleton (contract-first codegen + real backend + design
16316
+ # system, M1-M3); it never overwrites existing code or touches a brownfield
16317
+ # repo. On any doubt it no-ops and the normal build proceeds unchanged.
16318
+ if [ "${LOKI_SCAFFOLD_CONTRACT_FIRST:-0}" = "1" ]; then
16319
+ # shellcheck disable=SC1090,SC1091
16320
+ source "${SCRIPT_DIR}/lib/scaffold-hook.sh" 2>/dev/null || true
16321
+ if type run_contract_scaffold_hook >/dev/null 2>&1; then
16322
+ run_contract_scaffold_hook "${TARGET_DIR:-.}" "$prd_path" || true
16323
+ fi
16324
+ fi
16325
+
16257
16326
  # Auto-detect PRD if not provided
16258
16327
  if [ -z "$prd_path" ]; then
16259
16328
  log_step "No PRD provided, searching for existing PRD files..."
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.121.2"
10
+ __version__ = "7.121.4"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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.121.2
5
+ **Version:** v7.121.4
6
6
 
7
7
  ---
8
8
 
@@ -396,7 +396,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
396
396
  # Run Loki Mode in Docker (Claude provider, API-key auth)
397
397
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
398
398
  -v $(pwd):/workspace -w /workspace \
399
- asklokesh/loki-mode:7.121.2 start ./my-spec.md
399
+ asklokesh/loki-mode:7.121.4 start ./my-spec.md
400
400
  ```
401
401
 
402
402
  ##### docker compose + .env (no host install)
@@ -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.121.2";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}
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.121.4";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=1A0B5967C64F275B64756E2164756E21
817
+ //# debugId=8F986B79CB65C96964756E2164756E21