loki-mode 7.53.0 → 7.55.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.53.0
6
+ # Loki Mode v7.55.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.53.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.55.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.53.0
1
+ 7.55.0
package/autonomy/run.sh CHANGED
@@ -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
@@ -8396,6 +8402,87 @@ _semantic_gate_and_surface() {
8396
8402
  return "$_rc"
8397
8403
  }
8398
8404
 
8405
+ # P1-4 invariant/property gate (bash-route parity, v7.51.0). The Bun route
8406
+ # ships an invariant toggle (loki-ts/src/runner/quality_gates.ts:2057,
8407
+ # `invariants: flag("LOKI_GATE_INVARIANTS", false)`) running
8408
+ # tests/detect-invariant-violations.sh --strict; the bash route had NO
8409
+ # counterpart, so the bash/Bun parity test reported "Only in bun:
8410
+ # LOKI_GATE_INVARIANTS". This wires the bash side, mirroring
8411
+ # enforce_semantic_integrity's structure byte-for-byte:
8412
+ # - detector --strict exit-code contract (tests/detect-invariant-violations.sh
8413
+ # :347-353): rc 1 iff CRITICAL/HIGH present, rc 0 otherwise.
8414
+ # - detector honors LOKI_SCAN_DIR (tests/detect-invariant-violations.sh:123),
8415
+ # so the wrapper exports it to the TARGET project (cwd alone does NOT
8416
+ # redirect the scan).
8417
+ # The PLURAL token LOKI_GATE_INVARIANTS is used deliberately to match the Bun
8418
+ # readToggles flag name; the detector's own reference comment suggests a
8419
+ # singular default-on variant (no trailing S), which is NOT used here (parity
8420
+ # needs the plural, and this gate is OPT-IN / default OFF like the semantic
8421
+ # gate).
8422
+ enforce_invariant_integrity() {
8423
+ local loki_dir="${TARGET_DIR:-.}/.loki"
8424
+ local quality_dir="$loki_dir/quality"
8425
+ mkdir -p "$quality_dir"
8426
+ local findings_file="$quality_dir/invariant-findings.txt"
8427
+ local detector="$SCRIPT_DIR/../tests/detect-invariant-violations.sh"
8428
+ local gate_timeout="${LOKI_GATE_TIMEOUT:-300}"
8429
+
8430
+ if [ ! -f "$detector" ]; then
8431
+ log_info "Invariant gate: detector not found, skipping (inconclusive)"
8432
+ rm -f "$findings_file" 2>/dev/null || true
8433
+ return 0
8434
+ fi
8435
+
8436
+ local output rc
8437
+ # --strict exits 1 iff CRITICAL/HIGH present; 0 otherwise (clean wrapper).
8438
+ output=$(cd "${TARGET_DIR:-.}" && LOKI_SCAN_DIR="${TARGET_DIR:-.}" \
8439
+ timeout "$gate_timeout" bash "$detector" --strict 2>&1)
8440
+ rc=$?
8441
+
8442
+ # timeout exit 124 -- inconclusive, never block on a hang (deny-filter)
8443
+ if [ "$rc" -eq 124 ]; then
8444
+ log_warn "Invariant gate: detector timed out after ${gate_timeout}s -- inconclusive"
8445
+ rm -f "$findings_file" 2>/dev/null || true
8446
+ return 0
8447
+ fi
8448
+
8449
+ if [ "$rc" -eq 1 ]; then
8450
+ # rc 1 == one or more CRITICAL/HIGH findings. Persist per-finding text.
8451
+ {
8452
+ echo "# Invariant findings (CRITICAL/HIGH block this completion)"
8453
+ echo "$output" | grep -E '\[(CRITICAL|HIGH|MEDIUM|LOW)\]' || true
8454
+ } > "$findings_file"
8455
+ log_warn "Invariant gate: CRITICAL/HIGH invariant violations detected -- BLOCK"
8456
+ return 1
8457
+ fi
8458
+
8459
+ # rc 0 (and any other non-1, non-124 code, e.g. a malformed run) -> PASS.
8460
+ # Route any MED/LOW advisory findings to the injection file, else clear it.
8461
+ local med_low
8462
+ med_low=$(echo "$output" | grep -E '\[(MEDIUM|LOW)\]' || true)
8463
+ if [ -n "$med_low" ]; then
8464
+ {
8465
+ echo "# Invariant advisory findings (MED/LOW, non-blocking)"
8466
+ echo "$med_low"
8467
+ } > "$findings_file"
8468
+ else
8469
+ rm -f "$findings_file" 2>/dev/null || true
8470
+ fi
8471
+ log_info "Invariant gate: PASS"
8472
+ return 0
8473
+ }
8474
+
8475
+ # Thin wrapper mirroring _semantic_gate_and_surface so the completion-promise
8476
+ # elif arm reads cleanly (`! _invariant_gate_and_surface`). Returns nonzero
8477
+ # ONLY when enforce_invariant_integrity saw an rc-1 (CRITICAL/HIGH) result; all
8478
+ # deny-filter cases already collapse to 0 inside enforce_invariant_integrity,
8479
+ # so this never blocks a clean run.
8480
+ _invariant_gate_and_surface() {
8481
+ local _rc=0
8482
+ enforce_invariant_integrity || _rc=$?
8483
+ return "$_rc"
8484
+ }
8485
+
8399
8486
  # ============================================================================
8400
8487
  # 3-Reviewer Parallel Code Review (v5.35.0)
8401
8488
  # Specialist pool from skills/quality-gates.md with blind review
@@ -15461,6 +15548,22 @@ else:
15461
15548
  log_warn "Completion claim rejected: semantic test-authenticity gate found CRITICAL/HIGH fake-test problem(s)."
15462
15549
  log_warn " Details under .loki/quality/semantic-findings.txt ; opt-in gate -- disable with LOKI_GATE_SEMANTIC_TESTS=false"
15463
15550
  # Fall through; keep iterating until the fake tests are fixed.
15551
+ # P1-4: invariant/property gate (OPT-IN, default OFF). Mirrors the
15552
+ # semantic arm above and the Bun route's invariants toggle
15553
+ # (loki-ts/src/runner/quality_gates.ts:2057). Guarded by
15554
+ # LOKI_GATE_INVARIANTS (PLURAL, to match the Bun readToggles flag
15555
+ # name; the detector's reference-comment singular default-on variant
15556
+ # without the trailing S is deliberately NOT used). Accepts "true" or
15557
+ # "1"; default OFF means the arm never runs (zero cost, never
15558
+ # blocks). When enabled it runs detect-invariant-violations.sh
15559
+ # --strict and rejects completion ONLY on a CRITICAL/HIGH (rc 1)
15560
+ # finding; clean / detector-absent / timeout / malformed all
15561
+ # collapse to a pass inside _invariant_gate_and_surface, so the
15562
+ # autonomous loop can never deadlock on a clean run.
15563
+ elif [ "$_completion_claimed" = 1 ] && { [ "${LOKI_GATE_INVARIANTS:-false}" = "true" ] || [ "${LOKI_GATE_INVARIANTS:-false}" = "1" ]; } && type _invariant_gate_and_surface &>/dev/null && ! _invariant_gate_and_surface; then
15564
+ log_warn "Completion claim rejected: invariant gate found CRITICAL/HIGH invariant/property violation(s)."
15565
+ log_warn " Details under .loki/quality/invariant-findings.txt ; opt-in gate -- disable with LOKI_GATE_INVARIANTS=false"
15566
+ # Fall through; keep iterating until the invariant violations are fixed.
15464
15567
  elif [ "$_completion_claimed" = 1 ]; then
15465
15568
  echo ""
15466
15569
  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.53.0"
10
+ __version__ = "7.55.0"
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.53.0
5
+ **Version:** v7.55.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.53.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.55.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.53.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.55.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=3BF6CF9B99A2BD7E64756E2164756E21
793
+ //# debugId=75540993435DE5C864756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.53.0'
60
+ __version__ = '7.55.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.53.0",
4
+ "version": "7.55.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.53.0",
5
+ "version": "7.55.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",
@@ -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
+ };
@@ -42,6 +42,8 @@ var { execFileSync } = require('child_process');
42
42
  var { AuditLog } = require('./log');
43
43
 
44
44
  var CROSSLINK_ACTION = 'audit_crosslink';
45
+ var MANIFEST_LINK_ACTION = 'audit_manifest_link';
46
+ var MANIFEST_FILE = 'loki-run.json';
45
47
  var WITNESS_FILE = 'witness.jsonl';
46
48
  var PY_GENESIS = '0'.repeat(64);
47
49
 
@@ -400,6 +402,185 @@ function verifyUnified(opts) {
400
402
  };
401
403
  }
402
404
 
405
+ /**
406
+ * Resolve the path to the run manifest (bill-of-materials) written by
407
+ * autonomy/run.sh at <project>/.loki/loki-run.json. Override via
408
+ * opts.manifestPath for tests / non-standard layouts (mirrors the
409
+ * explicit-override idiom used by witnessFile / dashboardAuditDir).
410
+ */
411
+ function defaultManifestPath(opts) {
412
+ opts = opts || {};
413
+ if (opts.manifestPath) return opts.manifestPath;
414
+ return path.join((opts.projectDir || process.cwd()), '.loki', MANIFEST_FILE);
415
+ }
416
+
417
+ /**
418
+ * Hash the manifest exactly as run.sh's _loki_sha256 does: sha256 over the
419
+ * raw file BYTES. We deliberately do NOT JSON.parse-then-re-stringify
420
+ * (that would diverge from the on-disk bytes run.sh hashes and be fragile
421
+ * to formatting). We additionally best-effort parse the bytes only to lift
422
+ * the manifest `schema` field into anchor metadata; a malformed manifest
423
+ * still hashes its bytes and records schema:null rather than aborting.
424
+ *
425
+ * @returns {object} { present, sha256, schema } -- present:false when the
426
+ * file is absent (no fabricated hash).
427
+ */
428
+ function hashManifest(manifestPath) {
429
+ if (!manifestPath || !fs.existsSync(manifestPath)) {
430
+ return { present: false, sha256: null, schema: null };
431
+ }
432
+ var buf = fs.readFileSync(manifestPath);
433
+ var sha = crypto.createHash('sha256').update(buf).digest('hex');
434
+ var schema = null;
435
+ try {
436
+ var parsed = JSON.parse(buf.toString('utf8'));
437
+ if (parsed && typeof parsed.schema === 'string') schema = parsed.schema;
438
+ } catch (_) { /* malformed manifest: hash bytes anyway, schema stays null */ }
439
+ return { present: true, sha256: sha, schema: schema };
440
+ }
441
+
442
+ /**
443
+ * Link the run manifest (loki-run.json, the build bill-of-materials) into
444
+ * the agent audit chain so the manifest becomes tamper-evident and
445
+ * verifiable against the evidence chain.
446
+ *
447
+ * The manifest already embeds sha256 hashes of the evidence files
448
+ * (test_results, coverage, ...) computed by run.sh. By recording the
449
+ * manifest's OWN byte-hash as an `audit_manifest_link` entry, the anchor
450
+ * is protected by the agent chain's hash linkage, and the evidence hashes
451
+ * inside the manifest become transitively tamper-evident (mutating the
452
+ * manifest to point at different evidence changes its byte-hash, which no
453
+ * longer matches the anchored hash; mutating the anchor breaks chain
454
+ * verification). We hash the manifest itself only -- we do NOT re-hash the
455
+ * evidence files here (run.sh already did, and the manifest pins them).
456
+ *
457
+ * HONEST behavior:
458
+ * - Manifest absent -> no-op, returns { linked:false, present:false }.
459
+ * No fabricated link is recorded.
460
+ * - Manifest present -> hash recorded as an anchor; returns
461
+ * { linked:true, present:true, anchor, manifestSha256, ... }.
462
+ *
463
+ * Note: this records tamper-EVIDENCE (in-place edits are detected), not
464
+ * tamper-PROOF against a full downstream chain rewrite -- that is what
465
+ * writeWitness (external witness) is for.
466
+ *
467
+ * NOT auto-invoked from run.sh in this wave (integration is the run.sh
468
+ * owner's territory). Intended call site: after run.sh writes
469
+ * .loki/loki-run.json in build_completion_summary (autonomy/run.sh ~2895,
470
+ * just after os.replace(tmp, out)), call
471
+ * node -e "require('./src/audit').linkManifest({projectDir:'<dir>'})"
472
+ * (or the JS API audit.linkManifest()) on every terminal path.
473
+ *
474
+ * @param {object} [opts]
475
+ * @param {string} [opts.projectDir] project dir for the agent log + manifest
476
+ * @param {string} [opts.logDir] explicit agent log dir (tests)
477
+ * @param {string} [opts.manifestPath] explicit manifest path (tests)
478
+ * @param {string} [opts.who] actor recorded on the anchor
479
+ * @returns {object}
480
+ */
481
+ function linkManifest(opts) {
482
+ opts = opts || {};
483
+ var manifestPath = defaultManifestPath(opts);
484
+ var h = hashManifest(manifestPath);
485
+ if (!h.present) {
486
+ return {
487
+ linked: false, present: false, manifestPath: manifestPath,
488
+ reason: 'run manifest absent (no-op)',
489
+ };
490
+ }
491
+ var log = new AuditLog(opts);
492
+ var anchor = log.record({
493
+ who: opts.who || 'audit-manifest-link',
494
+ what: MANIFEST_LINK_ACTION,
495
+ where: manifestPath,
496
+ why: 'link run manifest (bill-of-materials) into agent audit chain',
497
+ metadata: {
498
+ manifestPath: manifestPath,
499
+ manifestSha256: h.sha256,
500
+ manifestSchema: h.schema,
501
+ },
502
+ });
503
+ log.flush();
504
+ log.destroy();
505
+ return {
506
+ linked: true, present: true, manifestPath: manifestPath,
507
+ manifestSha256: h.sha256, manifestSchema: h.schema, anchor: anchor,
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Verify the run-manifest link against the evidence chain.
513
+ *
514
+ * Composes TWO checks (mirroring verifyUnified rather than a bare disk-vs
515
+ * -recorded compare):
516
+ * 1. Agent chain integrity (AuditLog.verifyChain()). This catches an
517
+ * edit to the ANCHOR entry itself (e.g. someone rewrites the recorded
518
+ * manifestSha256), not just an edit to the manifest file.
519
+ * 2. Manifest reconciliation: re-hash the on-disk manifest and require
520
+ * it to equal the hash recorded by the MOST RECENT manifest-link
521
+ * anchor. A mutated manifest no longer matches -> tamper detected.
522
+ *
523
+ * HONEST empty cases (distinguishable from a real pass via `present`):
524
+ * - No anchor recorded yet -> { present:false, valid:true, reason:... }.
525
+ * - Anchor exists but the manifest file is now gone -> manifest.valid
526
+ * is false (the pinned manifest is missing/cannot be reconciled).
527
+ *
528
+ * @param {object} [opts] projectDir / logDir / manifestPath as linkManifest.
529
+ * @returns {object} { valid, present, chain, manifest }
530
+ */
531
+ function verifyManifestLink(opts) {
532
+ opts = opts || {};
533
+ var manifestPath = defaultManifestPath(opts);
534
+
535
+ var log = new AuditLog(opts);
536
+ var chain = log.verifyChain();
537
+ var entries = log.readEntries();
538
+ log.destroy();
539
+
540
+ var anchors = entries.filter(function (e) {
541
+ return e.what === MANIFEST_LINK_ACTION;
542
+ });
543
+
544
+ if (anchors.length === 0) {
545
+ return {
546
+ valid: !!chain.valid, present: false, chain: chain,
547
+ manifest: { present: false, valid: true, reason: 'no manifest-link anchor recorded' },
548
+ };
549
+ }
550
+
551
+ // Most recent anchor pins the current manifest state.
552
+ var anchor = anchors[anchors.length - 1];
553
+ var pinned = (anchor.metadata && anchor.metadata.manifestSha256) || null;
554
+
555
+ var current = hashManifest(manifestPath);
556
+ var manifest = {
557
+ present: true,
558
+ valid: true,
559
+ manifestPath: manifestPath,
560
+ pinnedSha256: pinned,
561
+ currentSha256: current.sha256,
562
+ anchorSeq: anchor.seq,
563
+ error: null,
564
+ };
565
+
566
+ if (!current.present) {
567
+ manifest.valid = false;
568
+ manifest.error = 'manifest pinned by anchor seq ' + anchor.seq +
569
+ ' is missing on disk';
570
+ } else if (current.sha256 !== pinned) {
571
+ manifest.valid = false;
572
+ manifest.error = 'manifest hash mismatch at anchor seq ' + anchor.seq +
573
+ ' (manifest tampered after linking)';
574
+ }
575
+
576
+ return {
577
+ valid: !!chain.valid && manifest.valid,
578
+ present: true,
579
+ chain: chain,
580
+ manifest: manifest,
581
+ };
582
+ }
583
+
403
584
  module.exports = {
404
585
  crossLink: crossLink,
405
586
  verifyUnified: verifyUnified,
@@ -409,5 +590,10 @@ module.exports = {
409
590
  agentChainTip: agentChainTip,
410
591
  unifiedRoot: unifiedRoot,
411
592
  defaultDashboardAuditDir: defaultDashboardAuditDir,
593
+ linkManifest: linkManifest,
594
+ verifyManifestLink: verifyManifestLink,
595
+ hashManifest: hashManifest,
596
+ defaultManifestPath: defaultManifestPath,
412
597
  CROSSLINK_ACTION: CROSSLINK_ACTION,
598
+ MANIFEST_LINK_ACTION: MANIFEST_LINK_ACTION,
413
599
  };
@@ -228,6 +228,27 @@ function writeWitness(opts) {
228
228
  return crosslink.writeWitness(Object.assign({ projectDir: _projectDir }, opts || {}));
229
229
  }
230
230
 
231
+ /**
232
+ * Link the run manifest (loki-run.json bill-of-materials) into the agent
233
+ * audit chain so it becomes tamper-evident and verifiable against the
234
+ * evidence chain. No-op (honest) when the manifest is absent.
235
+ * See src/audit/crosslink.js (linkManifest).
236
+ */
237
+ function linkManifest(opts) {
238
+ if (!_initialized) init();
239
+ return crosslink.linkManifest(Object.assign({ projectDir: _projectDir }, opts || {}));
240
+ }
241
+
242
+ /**
243
+ * Verify the run-manifest link: agent chain integrity AND the on-disk
244
+ * manifest still matching the hash pinned by the most recent
245
+ * manifest-link anchor. See src/audit/crosslink.js (verifyManifestLink).
246
+ */
247
+ function verifyManifestLink(opts) {
248
+ if (!_initialized) init();
249
+ return crosslink.verifyManifestLink(Object.assign({ projectDir: _projectDir }, opts || {}));
250
+ }
251
+
231
252
  /**
232
253
  * Destroy audit trail (for testing).
233
254
  */
@@ -255,6 +276,8 @@ module.exports = {
255
276
  crossLink: crossLink,
256
277
  verifyUnified: verifyUnified,
257
278
  writeWitness: writeWitness,
279
+ linkManifest: linkManifest,
280
+ verifyManifestLink: verifyManifestLink,
258
281
  };
259
282
 
260
283
  // CLI entry point: `node src/audit/index.js report <type> <projectDir>`.