loki-mode 7.52.0 → 7.54.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/README.md CHANGED
@@ -40,7 +40,7 @@ _The free, source-available autonomous coding agent by [Autonomi](https://www.au
40
40
  - **Legacy system healing** -- `loki modernize heal` archaeology/stabilize/isolate/modernize/validate phases (v6.67.0, see `skills/healing.md`)
41
41
  - **MCP server** -- 34 tools (including ChromaDB code search) plus 3 resources and 2 prompts (`mcp/server.py`, with magic tools registered from `mcp/magic_tools.py` and the managed-memory tool from `mcp/managed_tools.py`). Of the 34, 33 are always available; `loki_memory_redact` is registered but only succeeds when `LOKI_MANAGED_AGENTS=true` and `LOKI_MANAGED_MEMORY=true`. Launch with `loki mcp` (bootstraps the Python MCP SDK on first run).
42
42
  - **Full-stack output** -- Source code, tests, Docker Compose stacks (multi-service with healthchecks), CI/CD pipelines, audit logs
43
- - **Provider-agnostic** -- runs on Claude, Codex, Cline, or Aider with automatic failover (`loki-ts/src/runner/providers.ts`); no vendor lock-in. Gemini CLI deprecated v7.5.18; Antigravity CLI coming soon.
43
+ - **Provider-agnostic** -- runs on Claude, Codex, Cline, or Aider with automatic failover (`loki-ts/src/runner/providers.ts`); no vendor lock-in. Gemini CLI deprecated v7.5.18.
44
44
  - **Source-available (BUSL-1.1)** -- Free for personal, internal, and academic use.
45
45
 
46
46
  ---
@@ -326,7 +326,6 @@ Loki's autonomy and quality loop are the product; the underlying coding CLI is s
326
326
  | **Cline CLI** | Experimental (Tier 2) | `-y` | Sequential | `npm i -g @anthropic-ai/cline` |
327
327
  | **Aider** | Experimental (Tier 3) | `--yes-always` | Sequential | `pip install aider-chat` |
328
328
  | **Google Gemini CLI** | DEPRECATED v7.5.18 | -- | -- | Upstream deprecated; runtime removed. `LOKI_PROVIDER=gemini` exits with migration message. |
329
- | **Anthropic Antigravity CLI** | Coming soon | -- | -- | Integration planned. |
330
329
 
331
330
  Status legend: "E2E-verified" means we run real spec-to-code builds on it ourselves. Claude Code is the primary, fully supported provider and the one Loki Mode is built for; it gets full features (subagents, parallelization, MCP, Task tool). "Experimental" means the wiring is in place but we have not produced an end-to-end verified build ourselves; treat as community-tested. Experimental providers run sequentially. Auto-failover switches providers when rate-limited. See [Provider Guide](skills/providers.md).
332
331
 
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.52.0
6
+ # Loki Mode v7.54.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -238,7 +238,6 @@ loki docker --image IMG start prd.md # override the image
238
238
  - **Cline**: Multi-provider CLI, degraded mode (sequential only, no Task tool)
239
239
  - **Aider**: 18+ provider backends, degraded mode (sequential only, no Task tool)
240
240
  - **Google Gemini CLI**: DEPRECATED starting v7.5.18 (upstream deprecated; runtime removed)
241
- - **Anthropic Antigravity CLI**: Coming soon
242
241
 
243
242
  ---
244
243
 
@@ -407,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
407
406
 
408
407
  ---
409
408
 
410
- **v7.52.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.54.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.52.0
1
+ 7.54.0
package/autonomy/loki CHANGED
@@ -743,7 +743,7 @@ show_help() {
743
743
  echo " --complex Force complex complexity tier (8 phases)"
744
744
  echo " --github Enable GitHub issue import"
745
745
  echo " --no-dashboard Disable web dashboard"
746
- echo " --sandbox Run in Docker sandbox for isolation"
746
+ echo " --sandbox Run in Docker sandbox for isolation (default: off; requires Docker)"
747
747
  echo " --skip-memory Skip loading memory context at startup"
748
748
  echo " --fresh-prd Regenerate the PRD from the codebase (no-PRD runs; ignores the reusable generated PRD)"
749
749
  echo " --compliance PRESET Enable compliance mode (default|healthcare|fintech|government)"
@@ -1063,7 +1063,7 @@ cmd_start() {
1063
1063
  echo " --github Enable GitHub issue import"
1064
1064
  echo " --no-dashboard Disable web dashboard"
1065
1065
  echo " --api Start dashboard API server alongside the build"
1066
- echo " --sandbox Run in Docker sandbox"
1066
+ echo " --sandbox Run in Docker sandbox (default: off; requires Docker)"
1067
1067
  echo " --skip-memory Skip loading memory context at startup"
1068
1068
  echo " --fresh-prd Regenerate the PRD from the codebase on a no-PRD run"
1069
1069
  echo " (ignores the reusable generated PRD; aliases: --regen-prd,"
@@ -5680,7 +5680,7 @@ cmd_run() {
5680
5680
  echo " --simple Force simple complexity tier"
5681
5681
  echo " --complex Force complex complexity tier"
5682
5682
  echo " --no-dashboard Disable web dashboard"
5683
- echo " --sandbox Run in Docker sandbox"
5683
+ echo " --sandbox Run in Docker sandbox (default: off; requires Docker)"
5684
5684
  echo " --no-plan Skip auto-shown PRD analysis at startup"
5685
5685
  echo " --budget USD Set cost budget limit"
5686
5686
  echo ""
@@ -422,7 +422,11 @@ checklist_should_verify() {
422
422
  # non-cooperative agent with filesystem tools can read the reservation directly.
423
423
  #
424
424
  # Selection is idempotent and reproducible: count = clamp(round(0.25*N), 1, 5)
425
- # for N>=4 items; ordering by sha256 of each item's "id" (stable, not random).
425
+ # for N>=2 items; ordering by sha256 of each item's "id" (stable, not random).
426
+ # Small checklists (2 <= N < 4) reserve exactly 1 held-out item via the same
427
+ # sha256-rank selection (the clamp floor of 1 guarantees coverage), so a small
428
+ # spec's checklist is never fully gameable. N<2 is a no-op: holding out the only
429
+ # item of a 1-item checklist would leave nothing to verify against in the loop.
426
430
  # Written once to .loki/checklist/held-out.json; never overwritten if present.
427
431
  checklist_select_heldout() {
428
432
  local heldout_file="${CHECKLIST_DIR:-".loki/checklist"}/held-out.json"
@@ -442,7 +446,7 @@ checklist_select_heldout() {
442
446
  # PARTIAL kept=k dropped=d - some prior ids survived; we keep only survivors
443
447
  # DUP_SKIP - current checklist ids are not unique; the id-based
444
448
  # mechanism is unsound, so we reserve nothing (MEDIUM-2)
445
- # NOOP - n<4 with no prior file, or other no-write outcome
449
+ # NOOP - n<2 with no prior file, or other no-write outcome
446
450
  # Honest caveat: re-selection or partial-survival after a regen can reserve
447
451
  # items the build loop already saw in earlier prompts (the hidden-from-loop
448
452
  # guarantee is best-effort once the checklist ids change mid-run).
@@ -512,7 +516,7 @@ if os.path.exists(out_path):
512
516
 
513
517
  if prior is not None:
514
518
  prior_ids = [i for i in prior.get('held_out', []) if i]
515
- # A prior reservation of [] (e.g. an earlier n<4 run) is a valid no-op state;
519
+ # A prior reservation of [] (e.g. an earlier n<2 run) is a valid no-op state;
516
520
  # keep it idempotent rather than re-selecting now that n may have grown.
517
521
  if not prior_ids:
518
522
  print('IDEMPOTENT')
@@ -525,9 +529,11 @@ if prior is not None:
525
529
  if not survivors:
526
530
  # Fully stale: the checklist regenerated and orphaned the reservation.
527
531
  # Deterministically re-select from the CURRENT checklist.
528
- if n < 4:
532
+ if n < 2:
533
+ # N<2: cannot hold out from a 1-item checklist (reserving the only
534
+ # item leaves nothing to verify against). No-op write of an empty set.
529
535
  atomic_write({'held_out': [], 'total_items': n,
530
- 'note': 'n<4: no held-out reserved (re-selected after stale reservation)'})
536
+ 'note': 'n<2: no held-out reserved (re-selected after stale reservation)'})
531
537
  print('RESELECTED 0')
532
538
  sys.exit(0)
533
539
  held = fresh_selection()
@@ -542,11 +548,15 @@ if prior is not None:
542
548
  sys.exit(0)
543
549
 
544
550
  # No prior reservation: first selection.
545
- if n < 4:
546
- # N>=4 gate: smaller checklists get no held-out (nothing to hide reliably).
547
- atomic_write({'held_out': [], 'total_items': n, 'note': 'n<4: no held-out reserved'})
551
+ if n < 2:
552
+ # N<2 gate: a 1-item (or empty) checklist cannot meaningfully hold out an
553
+ # item -- reserving the only item would leave nothing to verify against in
554
+ # the build loop. Write an empty set so downstream reads stay well-formed.
555
+ atomic_write({'held_out': [], 'total_items': n, 'note': 'n<2: no held-out reserved'})
548
556
  print('NOOP')
549
557
  sys.exit(0)
558
+ # For 2 <= N < 4, fresh_selection() reserves exactly 1 item (select_count clamps
559
+ # round(0.25*N) up to a floor of 1), so small specs are never fully gameable.
550
560
 
551
561
  held = fresh_selection()
552
562
  atomic_write({'held_out': held, 'total_items': n})
package/autonomy/run.sh CHANGED
@@ -585,7 +585,7 @@ BACKGROUND_MODE=${LOKI_BACKGROUND:-false} # Run in background
585
585
  STAGED_AUTONOMY=${LOKI_STAGED_AUTONOMY:-false} # Require plan approval
586
586
  AUDIT_LOG_ENABLED=${LOKI_AUDIT_LOG:-true} # Enable audit logging (on by default)
587
587
  MAX_PARALLEL_AGENTS=${LOKI_MAX_PARALLEL_AGENTS:-10} # Limit concurrent agents
588
- SANDBOX_MODE=${LOKI_SANDBOX_MODE:-false} # Docker sandbox mode
588
+ SANDBOX_MODE=${LOKI_SANDBOX_MODE:-false} # Docker sandbox mode (informational; the real dispatch reads LOKI_SANDBOX_MODE at autonomy/loki:1965 and execs sandbox.sh -- this var is not consumed in run.sh)
589
589
  ALLOWED_PATHS=${LOKI_ALLOWED_PATHS:-""} # Empty = all paths allowed
590
590
  BLOCKED_COMMANDS=${LOKI_BLOCKED_COMMANDS:-"rm -rf /,dd if=,mkfs,:(){ :|:& };:"}
591
591
 
@@ -1454,7 +1454,13 @@ stop_enterprise_services() {
1454
1454
  # Exit 0 = ALLOW, Exit 1 = DENY, Exit 2 = REQUIRE_APPROVAL (logged but allowed for now)
1455
1455
  check_policy() {
1456
1456
  local enforcement_point="$1"
1457
- local context_json="${2:-{}}"
1457
+ # Default to a valid empty-object JSON. Do NOT inline `${2:-{}}`: the
1458
+ # closing brace of the parameter expansion eats the first `}` of the
1459
+ # `{}` default, so a non-empty $2 like {"a":1} would pass through as
1460
+ # {"a":1}} (invalid JSON -> check.js JSON.parse fails -> exit 1 DENY
1461
+ # every iteration). Split the default assignment to avoid the footgun.
1462
+ local context_json="${2:-}"
1463
+ [ -z "$context_json" ] && context_json='{}'
1458
1464
 
1459
1465
  # Only check if policy files exist
1460
1466
  if [ ! -f ".loki/policies.json" ] && [ ! -f ".loki/policies.yaml" ]; then
@@ -8313,6 +8319,89 @@ enforce_mutation_integrity() {
8313
8319
  return 0
8314
8320
  }
8315
8321
 
8322
+ # ============================================================================
8323
+ # Semantic Test-Authenticity Gate (P1-3): wire tests/detect-semantic-test-problems.sh
8324
+ # as an OPT-IN completion gate. The detector catches the harder class of fake
8325
+ # tests that the regex detectors (gates 5+6) miss: assertions that look real but
8326
+ # verify nothing because the asserted value never flows through code under test
8327
+ # (literal-via-variable echo HIGH, mock-return echo MED, deleted assertions MED).
8328
+ #
8329
+ # ADVISORY-FIRST POSTURE (no-deadlock contract): this helper is invoked ONLY when
8330
+ # LOKI_GATE_SEMANTIC_TESTS=true (the elif guard at the completion-promise arm
8331
+ # short-circuits when off, so there is zero runtime cost on the default path).
8332
+ # When on, it runs the detector with --block-high (clean exit-code contract:
8333
+ # rc 2 iff a CRITICAL/HIGH finding exists). We surface ALL severities to a
8334
+ # findings file (advisory) and return nonzero ONLY on rc 2. Every other exit --
8335
+ # rc 0 (clean), rc 124 (timeout), detector absent, no test files, malformed
8336
+ # output -- returns 0 (pass/fall-through), so the autonomous loop can NEVER
8337
+ # deadlock on a clean run. Mirrors enforce_mock_integrity's invocation
8338
+ # (cd TARGET_DIR + LOKI_SCAN_DIR=TARGET_DIR + timeout), swapping --strict for
8339
+ # --block-high and deciding on the rc-2 contract instead of grepping stdout.
8340
+ # ============================================================================
8341
+ enforce_semantic_integrity() {
8342
+ local loki_dir="${TARGET_DIR:-.}/.loki"
8343
+ local quality_dir="$loki_dir/quality"
8344
+ mkdir -p "$quality_dir"
8345
+ local findings_file="$quality_dir/semantic-findings.txt"
8346
+ local detector="$SCRIPT_DIR/../tests/detect-semantic-test-problems.sh"
8347
+ local gate_timeout="${LOKI_GATE_TIMEOUT:-300}"
8348
+
8349
+ if [ ! -f "$detector" ]; then
8350
+ log_info "Semantic test gate: detector not found, skipping (inconclusive)"
8351
+ rm -f "$findings_file" 2>/dev/null || true
8352
+ return 0
8353
+ fi
8354
+
8355
+ local output rc
8356
+ # --block-high exits 2 iff CRITICAL/HIGH present; 0 otherwise (clean wrapper).
8357
+ output=$(cd "${TARGET_DIR:-.}" && LOKI_SCAN_DIR="${TARGET_DIR:-.}" \
8358
+ timeout "$gate_timeout" bash "$detector" --block-high 2>&1)
8359
+ rc=$?
8360
+
8361
+ # timeout exit 124 -- inconclusive, never block on a hang (deny-filter)
8362
+ if [ "$rc" -eq 124 ]; then
8363
+ log_warn "Semantic test gate: detector timed out after ${gate_timeout}s -- inconclusive"
8364
+ rm -f "$findings_file" 2>/dev/null || true
8365
+ return 0
8366
+ fi
8367
+
8368
+ if [ "$rc" -eq 2 ]; then
8369
+ # rc 2 == one or more CRITICAL/HIGH findings. Persist per-finding text.
8370
+ {
8371
+ echo "# Semantic test-authenticity findings (CRITICAL/HIGH block this completion)"
8372
+ echo "$output" | grep -E '\[(CRITICAL|HIGH|MEDIUM|LOW)\]' || true
8373
+ } > "$findings_file"
8374
+ log_warn "Semantic test gate: CRITICAL/HIGH fake-test problems detected -- BLOCK"
8375
+ return 1
8376
+ fi
8377
+
8378
+ # rc 0 (and any other non-2, non-124 code, e.g. a malformed run) -> PASS.
8379
+ # Route any MED/LOW advisory findings to the injection file, else clear it.
8380
+ local med_low
8381
+ med_low=$(echo "$output" | grep -E '\[(MEDIUM|LOW)\]' || true)
8382
+ if [ -n "$med_low" ]; then
8383
+ {
8384
+ echo "# Semantic test advisory findings (MED/LOW, non-blocking)"
8385
+ echo "$med_low"
8386
+ } > "$findings_file"
8387
+ else
8388
+ rm -f "$findings_file" 2>/dev/null || true
8389
+ fi
8390
+ log_info "Semantic test gate: PASS"
8391
+ return 0
8392
+ }
8393
+
8394
+ # P1-3 wrapper that runs the semantic gate and returns its exact rc, mirroring
8395
+ # _evidence_gate_and_surface so the completion-promise elif arm reads cleanly
8396
+ # (`! _semantic_gate_and_surface`). Returns nonzero ONLY when enforce_semantic_integrity
8397
+ # saw an rc-2 (CRITICAL/HIGH) result; all deny-filter cases already collapse to 0
8398
+ # inside enforce_semantic_integrity, so this never blocks a clean run.
8399
+ _semantic_gate_and_surface() {
8400
+ local _rc=0
8401
+ enforce_semantic_integrity || _rc=$?
8402
+ return "$_rc"
8403
+ }
8404
+
8316
8405
  # ============================================================================
8317
8406
  # 3-Reviewer Parallel Code Review (v5.35.0)
8318
8407
  # Specialist pool from skills/quality-gates.md with blind review
@@ -12248,6 +12337,23 @@ if d.get('blocked'):
12248
12337
  gate_failure_context="${gate_failure_context}FIX THESE ISSUES BEFORE PROCEEDING WITH NEW WORK."
12249
12338
  fi
12250
12339
 
12340
+ # P1-3: surface specific semantic test-authenticity findings (which fake test,
12341
+ # which line) when the opt-in gate (LOKI_GATE_SEMANTIC_TESTS) wrote them, so a
12342
+ # block converges: the agent gets the exact files/lines to fix rather than a
12343
+ # bare gate name. The file exists only when the gate ran AND found something
12344
+ # (cleared on clean), so this is zero-cost on the default path and when off.
12345
+ # Mirrors the static-analysis/test-results detail-surfacing above. Surfaced
12346
+ # whether the run blocked (CRIT/HIGH) or only advised (MED/LOW): both inform
12347
+ # the next iteration. Independent of gate-failures.txt presence (the
12348
+ # completion-promise arm does not append a gate token).
12349
+ if [ -f "${TARGET_DIR:-.}/.loki/quality/semantic-findings.txt" ]; then
12350
+ local sem_findings
12351
+ sem_findings=$(grep -E '\[(CRITICAL|HIGH|MEDIUM|LOW)\]' "${TARGET_DIR:-.}/.loki/quality/semantic-findings.txt" 2>/dev/null | head -20 || true)
12352
+ if [ -n "$sem_findings" ]; then
12353
+ gate_failure_context="${gate_failure_context} SEMANTIC TEST-AUTHENTICITY FINDINGS (fix the fake tests; an assertion must verify a value that flows through the code under test, not echo a literal back): ${sem_findings}"
12354
+ fi
12355
+ fi
12356
+
12251
12357
  # P2-2: high-severity spec-assumption context. When DISCOVERY recorded any
12252
12358
  # high-severity assumption (the spec was ambiguous in a high-impact place),
12253
12359
  # surface it to the build agent so it implements with the gap in view (or
@@ -15347,6 +15453,20 @@ else:
15347
15453
  log_warn "Completion claim rejected: assumption ledger gate found unresolved high-severity spec assumption(s)."
15348
15454
  log_warn " Details under .loki/council/assumption-block.json ; opt out with LOKI_ASSUMPTION_GATE=0"
15349
15455
  # Fall through; keep iterating until high-sev assumptions resolve.
15456
+ # P1-3: semantic test-authenticity gate (OPT-IN, default OFF). Catches
15457
+ # fake tests that look real but verify nothing (literal-via-variable
15458
+ # echo etc.) that the regex gates 5+6 miss. ADVISORY-FIRST: the arm is
15459
+ # guarded by LOKI_GATE_SEMANTIC_TESTS=true, so by default it never runs
15460
+ # (zero runtime cost, never blocks). When enabled it runs the detector
15461
+ # with --block-high and rejects the completion ONLY on a CRITICAL/HIGH
15462
+ # finding; clean / no-test-files / detector-absent / timeout / malformed
15463
+ # all collapse to a pass inside _semantic_gate_and_surface, so the
15464
+ # autonomous loop can never deadlock on a clean run. Mirrors the
15465
+ # evidence / held-out / assumption arms above.
15466
+ elif [ "$_completion_claimed" = 1 ] && [ "${LOKI_GATE_SEMANTIC_TESTS:-false}" = "true" ] && type _semantic_gate_and_surface &>/dev/null && ! _semantic_gate_and_surface; then
15467
+ log_warn "Completion claim rejected: semantic test-authenticity gate found CRITICAL/HIGH fake-test problem(s)."
15468
+ log_warn " Details under .loki/quality/semantic-findings.txt ; opt-in gate -- disable with LOKI_GATE_SEMANTIC_TESTS=false"
15469
+ # Fall through; keep iterating until the fake tests are fixed.
15350
15470
  elif [ "$_completion_claimed" = 1 ]; then
15351
15471
  echo ""
15352
15472
  if [ -n "$COMPLETION_PROMISE" ]; then
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.52.0"
10
+ __version__ = "7.54.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -3248,6 +3248,121 @@ async def get_audit_summary(days: int = 7):
3248
3248
  return audit.get_audit_summary(days=days)
3249
3249
 
3250
3250
 
3251
+ # Continuous compliance surface (P3-11).
3252
+ #
3253
+ # Exposes the agent audit chain's compliance posture as an always-available
3254
+ # live endpoint. There is NO background scheduler in this surface (that is
3255
+ # infra, out of scope): the report is regenerated from the CURRENT audit
3256
+ # state on every request, so the endpoint is "continuous" in the sense that
3257
+ # it always reflects live state -- never a stale cached snapshot.
3258
+ #
3259
+ # The report is produced by the authoritative Node compliance engine
3260
+ # (src/audit/index.js, the single source of truth for SOC2/ISO/GDPR control
3261
+ # mappings) via its `report` CLI shim, so the Python surface never
3262
+ # reimplements (and never drifts from) the mapping logic. The chain it reads
3263
+ # is the JS AGENT chain at <project>/.loki/audit/audit.jsonl -- a different
3264
+ # chain from the Python dashboard chain that /api/enterprise/audit serves
3265
+ # (the two are reconciled by the cross-link verifier, not merged), so this
3266
+ # endpoint deliberately does NOT gate on audit.is_audit_enabled() (that flag
3267
+ # governs the Python chain). When the agent chain has no entries the report
3268
+ # is returned honestly with totalAuditEntries == 0; no fabricated pass.
3269
+ _COMPLIANCE_TYPES = ("soc2", "iso27001", "gdpr")
3270
+
3271
+
3272
+ @app.get("/api/compliance", dependencies=[Depends(auth.require_scope("audit"))])
3273
+ def get_compliance_status(report_type: str = Query("soc2", alias="type")):
3274
+ """Live compliance status for the active project's agent audit chain.
3275
+
3276
+ Auth/tenant scoping: requires the `audit` scope (same gate as the
3277
+ /api/enterprise/audit family). The data is filesystem state scoped to
3278
+ the active project via _get_loki_dir(), exactly like the other
3279
+ .loki-backed read endpoints; there is no DB tenant_id on a JSONL file
3280
+ to enforce against.
3281
+
3282
+ Query: ?type=soc2|iso27001|gdpr (default soc2).
3283
+
3284
+ Returns the compliance report JSON regenerated from CURRENT audit
3285
+ state on every call. If no audit data has been recorded the report is
3286
+ honestly empty (totalAuditEntries == 0), not a fabricated compliant
3287
+ verdict. If the Node engine is unavailable, returns an honest
3288
+ available:false payload (HTTP 200) rather than masquerading as "no
3289
+ compliance".
3290
+ """
3291
+ if not _read_limiter.check("compliance"):
3292
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
3293
+ if report_type not in _COMPLIANCE_TYPES:
3294
+ raise HTTPException(
3295
+ status_code=400,
3296
+ detail=f"Invalid type: {report_type}. Must be one of {list(_COMPLIANCE_TYPES)}",
3297
+ )
3298
+
3299
+ import shutil
3300
+
3301
+ # The agent audit chain lives under <project>/.loki/audit; _get_loki_dir()
3302
+ # returns the .loki dir, so the project root is its parent.
3303
+ project_dir = str(_get_loki_dir().parent.resolve())
3304
+ repo_root = _Path(__file__).resolve().parent.parent
3305
+ index_js = repo_root / "src" / "audit" / "index.js"
3306
+
3307
+ node_bin = shutil.which("node")
3308
+ if node_bin is None or not index_js.exists():
3309
+ return {
3310
+ "available": False,
3311
+ "reason": (
3312
+ "Node runtime not found"
3313
+ if node_bin is None
3314
+ else f"compliance engine not found at {index_js}"
3315
+ ),
3316
+ "reportType": report_type,
3317
+ "projectDir": project_dir,
3318
+ "report": None,
3319
+ }
3320
+
3321
+ try:
3322
+ proc = subprocess.run(
3323
+ [node_bin, str(index_js), "report", report_type, project_dir],
3324
+ capture_output=True,
3325
+ text=True,
3326
+ timeout=30,
3327
+ check=False,
3328
+ )
3329
+ except (OSError, subprocess.SubprocessError) as exc:
3330
+ return {
3331
+ "available": False,
3332
+ "reason": f"compliance engine invocation failed: {exc}",
3333
+ "reportType": report_type,
3334
+ "projectDir": project_dir,
3335
+ "report": None,
3336
+ }
3337
+
3338
+ if proc.returncode != 0:
3339
+ return {
3340
+ "available": False,
3341
+ "reason": (proc.stderr or "compliance engine returned non-zero").strip()[:500],
3342
+ "reportType": report_type,
3343
+ "projectDir": project_dir,
3344
+ "report": None,
3345
+ }
3346
+
3347
+ try:
3348
+ report = json.loads(proc.stdout.strip())
3349
+ except json.JSONDecodeError:
3350
+ return {
3351
+ "available": False,
3352
+ "reason": "compliance engine produced non-JSON output",
3353
+ "reportType": report_type,
3354
+ "projectDir": project_dir,
3355
+ "report": None,
3356
+ }
3357
+
3358
+ return {
3359
+ "available": True,
3360
+ "reportType": report_type,
3361
+ "projectDir": project_dir,
3362
+ "report": report,
3363
+ }
3364
+
3365
+
3251
3366
  # =============================================================================
3252
3367
  # File-based Session Endpoints (reads from .loki/ flat files)
3253
3368
  # =============================================================================
@@ -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.52.0
5
+ **Version:** v7.54.0
6
6
 
7
7
  ---
8
8
 
@@ -312,7 +312,6 @@ Loki Mode supports four active providers across three tiers, plus historical/upc
312
312
  | `codex` | Active | Tier 3 (degraded) | Sequential only, no Task tool; aligned with `@openai/codex` v0.125+. |
313
313
  | `aider` | Active | Tier 3 (degraded) | Sequential only; `ollama_chat/<model>` works for local models. |
314
314
  | `gemini` | DEPRECATED v7.5.18 | -- | Upstream Gemini CLI deprecated by Google. Runtime removed; `LOKI_PROVIDER=gemini` exits with migration message. |
315
- | `antigravity` | Coming soon | -- | Anthropic Antigravity CLI integration planned. |
316
315
 
317
316
  ### Configuration
318
317
 
@@ -396,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
396
395
  # Run Loki Mode in Docker (Claude provider, API-key auth)
397
396
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
398
397
  -v $(pwd):/workspace -w /workspace \
399
- asklokesh/loki-mode:7.52.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.54.0 start ./my-spec.md
400
399
  ```
401
400
 
402
401
  ##### docker compose + .env (no host install)
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var D1={};h(D1,{lokiDir:()=>P,homeLokiDir:()=>n$,findRepoRootForVersion:()=>o$,REPO_ROOT:()=>g});import{resolve as n,dirname as d$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as P$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=d$($);if(Z===$)break;$=Z}return n(S1,"..","..","..")}function o$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=d$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function n$(){return n($Q(),".loki")}var S1,g;var b=L(()=>{S1=d$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function j$(){if($$!==null)return $$;let $="7.52.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=XQ(KQ(import.meta.url)),Z=o$(Q);$$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var a$=L(()=>{b()});var b1={};h(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>s$});async function k($,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([new Response(Z.stdout).text(),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 qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new s$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var s$;var d=L(()=>{s$=class s$ 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 a($){return JQ?"":$}var JQ,T,S,_,wZ,I,R,y,V;var c=L(()=>{JQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),_=a("\x1B[1;33m"),wZ=a("\x1B[0;34m"),I=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),V=a("\x1B[0m")});import{existsSync as wQ}from"fs";async function Q$(){if(G$!==void 0)return G$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return G$=$,$;let Q=await f("python3.12");if(Q)return G$=Q,Q;let Z=await f("python3");return G$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var G$;var q$=L(()=>{d()});var e1={};h(e1,{runStatus:()=>uQ});import{existsSync as v,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as C,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){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 a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*k$/Q);if(X>k$)X=k$;let q=k$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
2
+ var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var D1={};h(D1,{lokiDir:()=>P,homeLokiDir:()=>n$,findRepoRootForVersion:()=>o$,REPO_ROOT:()=>g});import{resolve as n,dirname as d$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as P$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=d$($);if(Z===$)break;$=Z}return n(S1,"..","..","..")}function o$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=d$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function n$(){return n($Q(),".loki")}var S1,g;var b=L(()=>{S1=d$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function j$(){if($$!==null)return $$;let $="7.54.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=XQ(KQ(import.meta.url)),Z=o$(Q);$$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var a$=L(()=>{b()});var b1={};h(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>s$});async function k($,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([new Response(Z.stdout).text(),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 qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new s$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var s$;var d=L(()=>{s$=class s$ 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 a($){return JQ?"":$}var JQ,T,S,_,wZ,I,R,y,V;var c=L(()=>{JQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),_=a("\x1B[1;33m"),wZ=a("\x1B[0;34m"),I=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),V=a("\x1B[0m")});import{existsSync as wQ}from"fs";async function Q$(){if(G$!==void 0)return G$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return G$=$,$;let Q=await f("python3.12");if(Q)return G$=Q,Q;let Z=await f("python3");return G$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var G$;var q$=L(()=>{d()});var e1={};h(e1,{runStatus:()=>uQ});import{existsSync as v,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as C,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){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 a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*k$/Q);if(X>k$)X=k$;let q=k$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
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)
@@ -790,4 +790,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
790
790
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
791
791
  `),process.stderr.write(s6),2}}l1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var KZ=await XZ(Bun.argv.slice(2));process.exit(KZ);
792
792
 
793
- //# debugId=D3609A2FE6BB9BAE64756E2164756E21
793
+ //# debugId=45D73957F12E90DF64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.52.0'
60
+ __version__ = '7.54.0'
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.52.0",
4
+ "version": "7.54.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.52.0",
5
+ "version": "7.54.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",
@@ -2,12 +2,14 @@
2
2
 
3
3
  **Never ship code without passing all quality gates.**
4
4
 
5
- ## The 8 Quality Gates
5
+ ## The Quality Gates (8 default-on + 1 opt-in)
6
6
 
7
- Every gate below is wired into the orchestration loop (`autonomy/run.sh`) and
8
- blocks completion when it fails. The table lists exactly what each gate detects,
9
- what it does NOT detect (so you never over-trust a green gate), its opt-out flag,
10
- and its blocking behavior. Transcribe this list verbatim; do not recompute it.
7
+ Every gate below is wired into the orchestration loop (`autonomy/run.sh`). The 8
8
+ numbered gates are default-on and block completion when they fail; the opt-in
9
+ gate (marked below) is default-OFF and runs only when its flag is set. The table
10
+ lists exactly what each gate detects, what it does NOT detect (so you never
11
+ over-trust a green gate), its opt-out flag, and its blocking behavior. Transcribe
12
+ this list verbatim; do not recompute it.
11
13
 
12
14
  | # | Gate | Detects | Does NOT detect | Blocking | Opt-out flag |
13
15
  |---|------|---------|-----------------|----------|--------------|
@@ -19,6 +21,7 @@ and its blocking behavior. Transcribe this list verbatim; do not recompute it.
19
21
  | 6 | Test Mutation Detector | Assertion-value churn alongside implementation changes (test-fitting), low assertion density (`tests/detect-test-mutations.sh`); HIGH blocks | Logically-correct-but-weak assertions | Yes (HIGH blocks) | `LOKI_GATE_MUTATION=false` |
20
22
  | 7 | Documentation Coverage | README presence, docs freshness within 10 commits, API docs for exported symbols in packages | Whether the docs are accurate or useful | Yes | `LOKI_GATE_DOC_COVERAGE=false` |
21
23
  | 8 | Magic Modules Debate | Spec-vs-implementation debate findings on generated Magic Modules; BLOCK-severity findings block | Issues outside the Magic Modules debate scope | Yes (BLOCK severity) | `LOKI_GATE_MAGIC_DEBATE=false` |
24
+ | 9 (opt-in, default OFF) | Semantic Test-Authenticity | Fake tests that look real but verify nothing (literal-via-variable echo, mock-return echo, deleted assertions) that gates 5+6 miss (`tests/detect-semantic-test-problems.sh --block-high`); CRITICAL/HIGH block | Deep dataflow, legitimate computed-literal assertions, Python/shell tests (JS/TS only); MED/LOW are advisory | Only when enabled, and only on CRITICAL/HIGH; runs solely on a completion claim | Opt-IN: `LOKI_GATE_SEMANTIC_TESTS=true` to enable (default off = not invoked, never blocks) |
22
25
 
23
26
  **Severity-based blocking** ties the review gates together: any Critical or High
24
27
  finding blocks completion. Medium, Low, and cosmetic findings are advisory and
@@ -0,0 +1,287 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Compliance snapshot scheduler (lightweight helper, NOT a daemon).
5
+ *
6
+ * v7.53.0 shipped a live GET /api/compliance endpoint that regenerates a
7
+ * compliance report on demand. This module is the remaining P3-11 piece:
8
+ * OPTIONAL scheduled/continuous generation so a compliance snapshot is
9
+ * periodically PERSISTED to disk. That gives two things a live dashboard
10
+ * call cannot:
11
+ *
12
+ * 1. Trend / history: a series of timestamped snapshots over time.
13
+ * 2. Air-gapped audit evidence: durable, self-contained proof on disk
14
+ * without anyone making a live API call.
15
+ *
16
+ * It is deliberately NOT a background process. The gate function
17
+ * (maybeGenerateSnapshot) is meant to be invoked opportunistically (for
18
+ * example once per autonomous run) and self-rate-limits: it only writes a
19
+ * new snapshot when the configured interval has elapsed since the last one.
20
+ * That makes it "continuous" when enabled without needing a daemon or any
21
+ * always-running loop.
22
+ *
23
+ * HONESTY: every snapshot is generated from the REAL current audit chain
24
+ * (AuditLog.readEntries) and the REAL tamper-evidence verdict
25
+ * (AuditLog.verifyChain), exactly like index.js getReport. An empty chain
26
+ * yields an honest empty snapshot (totalAuditEntries: 0), never a
27
+ * fabricated "compliant" verdict.
28
+ *
29
+ * DEFAULT DISABLED: with no configured interval (LOKI_COMPLIANCE_SNAPSHOT_INTERVAL_HOURS
30
+ * unset or 0), maybeGenerateSnapshot is a no-op and adds zero behavior for
31
+ * existing users.
32
+ *
33
+ * NOT YET AUTO-INVOKED: this wave ships the tested helper only. It is not
34
+ * wired into run.sh or any live loop here (that is integration, owned by the
35
+ * run.sh owners). See "How to invoke" below for the intended call site.
36
+ *
37
+ * How to invoke (intended integration, not yet wired):
38
+ * var scheduler = require('./src/audit/compliance-scheduler');
39
+ * // Once per run, after init:
40
+ * scheduler.maybeGenerateSnapshot({ projectDir: process.cwd() });
41
+ * // Reads LOKI_COMPLIANCE_SNAPSHOT_INTERVAL_HOURS; no-op unless elapsed.
42
+ */
43
+
44
+ var fs = require('fs');
45
+ var path = require('path');
46
+ var { AuditLog } = require('./log');
47
+ var compliance = require('./compliance');
48
+
49
+ var ENV_INTERVAL = 'LOKI_COMPLIANCE_SNAPSHOT_INTERVAL_HOURS';
50
+ var SNAPSHOT_DIRNAME = 'compliance-snapshots';
51
+ var MARKER_FILENAME = 'last-snapshot.json';
52
+ var MS_PER_HOUR = 3600 * 1000;
53
+
54
+ /**
55
+ * Parse a configured interval (hours) into a number. Returns 0 (disabled)
56
+ * for unset, empty, non-numeric, negative, or NaN values. 0 means the
57
+ * scheduler is disabled and maybeGenerateSnapshot is a no-op.
58
+ *
59
+ * @param {*} raw - Raw value (typically process.env.LOKI_COMPLIANCE_SNAPSHOT_INTERVAL_HOURS)
60
+ * @returns {number} Interval in hours, or 0 if disabled / invalid.
61
+ */
62
+ function parseIntervalHours(raw) {
63
+ if (raw === undefined || raw === null || raw === '') return 0;
64
+ var n = Number(raw);
65
+ if (!isFinite(n) || n <= 0) return 0;
66
+ return n;
67
+ }
68
+
69
+ /**
70
+ * Resolve the snapshot directory for a project: <projectDir>/.loki/audit/compliance-snapshots.
71
+ */
72
+ function snapshotDir(projectDir) {
73
+ return path.join(projectDir || process.cwd(), '.loki', 'audit', SNAPSHOT_DIRNAME);
74
+ }
75
+
76
+ /**
77
+ * Resolve the rate-limit marker file path.
78
+ */
79
+ function markerPath(projectDir) {
80
+ return path.join(snapshotDir(projectDir), MARKER_FILENAME);
81
+ }
82
+
83
+ /**
84
+ * Read the last-generated timestamp (ms) from the marker file. Returns null
85
+ * if no marker exists or it is unreadable / malformed (treated as "never
86
+ * generated" so the next call generates).
87
+ */
88
+ function readLastGeneratedAtMs(projectDir) {
89
+ var p = markerPath(projectDir);
90
+ try {
91
+ if (!fs.existsSync(p)) return null;
92
+ var raw = fs.readFileSync(p, 'utf8');
93
+ var obj = JSON.parse(raw);
94
+ var ms = Number(obj && obj.lastGeneratedAtMs);
95
+ if (!isFinite(ms)) return null;
96
+ return ms;
97
+ } catch (_) {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Build a compliance snapshot from the REAL audit chain for a project.
104
+ *
105
+ * Bundles all three report types (soc2, iso27001, gdpr), each generated
106
+ * from the live audit entries, plus the single shared tamper-evidence
107
+ * verdict. This mirrors index.js getReport's honesty: the chainIntegrity
108
+ * verdict comes from verifyChain(), and a verification error is recorded
109
+ * as a valid:false verdict rather than being allowed to throw or fabricate
110
+ * a pass. An empty chain produces totalAuditEntries: 0 honestly.
111
+ *
112
+ * This does NOT use the index.js singleton; it reads the chain directly so
113
+ * it is self-contained and free of shared-state coupling.
114
+ *
115
+ * @param {object} args
116
+ * @param {string} args.projectDir - Project root whose .loki/audit chain is read.
117
+ * @param {object} [args.reportOpts] - Options forwarded to the generators (projectName, period, etc.)
118
+ * @param {number} [args.nowMs] - Clock for generatedAt (defaults to Date.now()).
119
+ * @returns {object} The snapshot object.
120
+ */
121
+ function buildSnapshot(args) {
122
+ args = args || {};
123
+ var projectDir = args.projectDir || process.cwd();
124
+ var reportOpts = args.reportOpts || {};
125
+ var nowMs = (typeof args.nowMs === 'number') ? args.nowMs : Date.now();
126
+
127
+ var log = new AuditLog({ projectDir: projectDir });
128
+ var entries;
129
+ try {
130
+ entries = log.readEntries();
131
+ } catch (e) {
132
+ entries = [];
133
+ }
134
+
135
+ // Real tamper-evidence verdict. Do not let a verification error fabricate
136
+ // a pass: capture it honestly as a valid:false verdict instead.
137
+ var chainIntegrity;
138
+ try {
139
+ chainIntegrity = log.verifyChain();
140
+ } catch (e) {
141
+ chainIntegrity = {
142
+ valid: false,
143
+ entries: entries.length,
144
+ brokenAt: null,
145
+ error: 'chain verification failed: ' + String((e && e.message) || e),
146
+ };
147
+ }
148
+
149
+ try { log.destroy(); } catch (_) { /* noop */ }
150
+
151
+ var soc2 = compliance.generateSoc2Report(entries, reportOpts);
152
+ soc2.chainIntegrity = chainIntegrity;
153
+ var iso27001 = compliance.generateIso27001Report(entries, reportOpts);
154
+ iso27001.chainIntegrity = chainIntegrity;
155
+ var gdpr = compliance.generateGdprReport(entries, reportOpts);
156
+ gdpr.chainIntegrity = chainIntegrity;
157
+
158
+ return {
159
+ snapshotVersion: 1,
160
+ generatedAt: new Date(nowMs).toISOString(),
161
+ projectName: reportOpts.projectName || 'Loki Mode',
162
+ totalAuditEntries: entries.length,
163
+ chainIntegrity: chainIntegrity,
164
+ reports: {
165
+ soc2: soc2,
166
+ iso27001: iso27001,
167
+ gdpr: gdpr,
168
+ },
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Build a filesystem-safe snapshot filename from an ISO timestamp. ISO
174
+ * strings contain colons and dots which are fine on macOS/Linux but are
175
+ * sanitized to hyphens anyway for portability.
176
+ */
177
+ function snapshotFilename(isoTimestamp) {
178
+ var safe = String(isoTimestamp).replace(/[:.]/g, '-');
179
+ return 'compliance-' + safe + '.json';
180
+ }
181
+
182
+ /**
183
+ * Persist a snapshot to disk and update the rate-limit marker.
184
+ *
185
+ * Writes <snapshotDir>/compliance-<timestamp>.json and updates
186
+ * <snapshotDir>/last-snapshot.json with the generation clock so the next
187
+ * gate decision reads the same clock that produced the snapshot.
188
+ *
189
+ * @param {object} args
190
+ * @param {string} args.projectDir - Project root.
191
+ * @param {object} args.snapshot - Snapshot object (from buildSnapshot).
192
+ * @param {number} args.nowMs - Generation clock in ms (stored in the marker).
193
+ * @returns {string} The absolute path of the written snapshot file.
194
+ */
195
+ function persistSnapshot(args) {
196
+ var projectDir = args.projectDir || process.cwd();
197
+ var snapshot = args.snapshot;
198
+ var nowMs = args.nowMs;
199
+ var dir = snapshotDir(projectDir);
200
+ if (!fs.existsSync(dir)) {
201
+ fs.mkdirSync(dir, { recursive: true });
202
+ }
203
+ var file = path.join(dir, snapshotFilename(snapshot.generatedAt));
204
+ fs.writeFileSync(file, JSON.stringify(snapshot, null, 2), 'utf8');
205
+ fs.writeFileSync(
206
+ markerPath(projectDir),
207
+ JSON.stringify({ lastGeneratedAtMs: nowMs, lastSnapshotFile: path.basename(file) }, null, 2),
208
+ 'utf8'
209
+ );
210
+ return file;
211
+ }
212
+
213
+ /**
214
+ * Opportunistic, self-rate-limiting snapshot generation.
215
+ *
216
+ * Decides whether to generate a snapshot now based on the configured
217
+ * interval and the persisted last-generated timestamp:
218
+ *
219
+ * - Interval 0 / unset (default) -> no-op, reason 'disabled'.
220
+ * - No prior snapshot and interval > 0 -> generate (first run).
221
+ * - now - lastGeneratedAt >= interval -> generate.
222
+ * - Otherwise -> no-op, reason 'not-elapsed'.
223
+ *
224
+ * Both the interval and the clock are injectable via opts so callers (and
225
+ * tests) can control them; env is the fallback for the interval and
226
+ * Date.now() the fallback for the clock.
227
+ *
228
+ * Return contract:
229
+ * { generated: true, path: <file>, report: <snapshot>, intervalHours }
230
+ * { generated: false, reason: 'disabled', intervalHours: 0 }
231
+ * { generated: false, reason: 'not-elapsed', intervalHours, nextEligibleAtMs }
232
+ *
233
+ * @param {object} [opts]
234
+ * @param {string} [opts.projectDir] - Project root (default process.cwd()).
235
+ * @param {number} [opts.intervalHours] - Interval override; falls back to env.
236
+ * @param {number} [opts.now] - Current time in ms; falls back to Date.now().
237
+ * @param {object} [opts.reportOpts] - Options forwarded to report generators.
238
+ * @returns {object} Result per the return contract above.
239
+ */
240
+ function maybeGenerateSnapshot(opts) {
241
+ opts = opts || {};
242
+ var projectDir = opts.projectDir || process.cwd();
243
+ var intervalHours = (typeof opts.intervalHours === 'number')
244
+ ? parseIntervalHours(opts.intervalHours)
245
+ : parseIntervalHours(process.env[ENV_INTERVAL]);
246
+ var now = (typeof opts.now === 'number') ? opts.now : Date.now();
247
+
248
+ if (intervalHours <= 0) {
249
+ return { generated: false, reason: 'disabled', intervalHours: 0 };
250
+ }
251
+
252
+ var lastMs = readLastGeneratedAtMs(projectDir);
253
+ var intervalMs = intervalHours * MS_PER_HOUR;
254
+
255
+ if (lastMs !== null && (now - lastMs) < intervalMs) {
256
+ return {
257
+ generated: false,
258
+ reason: 'not-elapsed',
259
+ intervalHours: intervalHours,
260
+ nextEligibleAtMs: lastMs + intervalMs,
261
+ };
262
+ }
263
+
264
+ var snapshot = buildSnapshot({
265
+ projectDir: projectDir,
266
+ reportOpts: opts.reportOpts,
267
+ nowMs: now,
268
+ });
269
+ var file = persistSnapshot({ projectDir: projectDir, snapshot: snapshot, nowMs: now });
270
+
271
+ return {
272
+ generated: true,
273
+ path: file,
274
+ report: snapshot,
275
+ intervalHours: intervalHours,
276
+ };
277
+ }
278
+
279
+ module.exports = {
280
+ maybeGenerateSnapshot: maybeGenerateSnapshot,
281
+ buildSnapshot: buildSnapshot,
282
+ persistSnapshot: persistSnapshot,
283
+ parseIntervalHours: parseIntervalHours,
284
+ snapshotDir: snapshotDir,
285
+ markerPath: markerPath,
286
+ ENV_INTERVAL: ENV_INTERVAL,
287
+ };
@@ -83,6 +83,84 @@ function exportReport(type, opts) {
83
83
  return compliance.exportReportJson(report);
84
84
  }
85
85
 
86
+ /**
87
+ * Generate a compliance report as a plain object, with the agent-chain
88
+ * tamper-evidence verdict folded in.
89
+ *
90
+ * This is the object form intended for surfaces (e.g. the dashboard
91
+ * /api/compliance endpoint) that need the report as data rather than a
92
+ * pre-serialized string. It always reflects the REAL audit chain:
93
+ *
94
+ * - The report body is generated from the live audit entries
95
+ * (`_log.readEntries()`), never fabricated.
96
+ * - `chainIntegrity` is populated from `verifyChain()` so the report
97
+ * carries the true tamper-evidence state of the underlying chain.
98
+ * For the SOC2 report this fills the `chainIntegrity: null` slot the
99
+ * generator leaves for the caller; for the other report types it is
100
+ * attached under the same key for a uniform surface contract.
101
+ *
102
+ * When the chain has no entries the report is still returned honestly
103
+ * with `totalAuditEntries: 0` (an empty-but-valid report), never a
104
+ * fabricated "compliant" verdict.
105
+ *
106
+ * @param {string} type - 'soc2', 'iso27001', or 'gdpr'
107
+ * @param {object} [opts] - Report options (projectName, period, etc.)
108
+ * @returns {object} The compliance report object with chainIntegrity set.
109
+ */
110
+ function getReport(type, opts) {
111
+ if (!_initialized) init();
112
+ var report = generateReport(type, opts);
113
+ // Fold the real tamper-evidence verdict into the report. Do not let a
114
+ // verification error fabricate a pass: capture it honestly instead.
115
+ try {
116
+ report.chainIntegrity = _log.verifyChain();
117
+ } catch (e) {
118
+ report.chainIntegrity = {
119
+ valid: false,
120
+ entries: report.totalAuditEntries || 0,
121
+ brokenAt: null,
122
+ error: 'chain verification failed: ' + String((e && e.message) || e),
123
+ };
124
+ }
125
+ return report;
126
+ }
127
+
128
+ /**
129
+ * CLI shim so a non-Node surface (e.g. the Python dashboard) can fetch a
130
+ * compliance report for a given project directory as JSON on stdout.
131
+ *
132
+ * This mirrors the inverse of dashboard/audit.py's `_unified_cli()`
133
+ * (which lets the Node-side unified verifier read the Python chain).
134
+ *
135
+ * Invoked as:
136
+ * node src/audit/index.js report <type> <projectDir>
137
+ *
138
+ * <type> is one of soc2 | iso27001 | gdpr. <projectDir> is the project
139
+ * root whose .loki/audit/audit.jsonl chain is read. Prints a single JSON
140
+ * object to stdout. Returns exit 0 on success, 2 on usage error.
141
+ *
142
+ * The report is generated from the REAL chain; an absent/empty chain
143
+ * yields an honest empty report (totalAuditEntries: 0), not a fake pass.
144
+ */
145
+ function _cli(argv) {
146
+ var args = argv || [];
147
+ var VALID_TYPES = { soc2: true, iso27001: true, gdpr: true };
148
+ if (args.length < 2 || args[0] !== 'report' || !VALID_TYPES[args[1]]) {
149
+ process.stdout.write(JSON.stringify({
150
+ error: 'usage: index.js report {soc2|iso27001|gdpr} <projectDir>',
151
+ }) + '\n');
152
+ return 2;
153
+ }
154
+ var type = args[1];
155
+ var projectDir = args[2] || process.cwd();
156
+ destroy();
157
+ init(projectDir);
158
+ var report = getReport(type);
159
+ destroy();
160
+ process.stdout.write(JSON.stringify(report) + '\n');
161
+ return 0;
162
+ }
163
+
86
164
  /**
87
165
  * Check if a provider is allowed by data residency policy.
88
166
  */
@@ -167,6 +245,7 @@ module.exports = {
167
245
  verifyChain: verifyChain,
168
246
  generateReport: generateReport,
169
247
  exportReport: exportReport,
248
+ getReport: getReport,
170
249
  checkProvider: checkProvider,
171
250
  isAirGapped: isAirGapped,
172
251
  readEntries: readEntries,
@@ -177,3 +256,8 @@ module.exports = {
177
256
  verifyUnified: verifyUnified,
178
257
  writeWitness: writeWitness,
179
258
  };
259
+
260
+ // CLI entry point: `node src/audit/index.js report <type> <projectDir>`.
261
+ if (require.main === module) {
262
+ process.exit(_cli(process.argv.slice(2)));
263
+ }