loki-mode 7.129.5 → 8.0.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.
Files changed (74) hide show
  1. package/README.md +81 -5
  2. package/SKILL.md +66 -5
  3. package/VERSION +1 -1
  4. package/autonomy/app-runner.sh +37 -0
  5. package/autonomy/completion-council.sh +862 -28
  6. package/autonomy/council-v2.sh +39 -0
  7. package/autonomy/crash.sh +3 -2
  8. package/autonomy/grill.sh +42 -0
  9. package/autonomy/hooks/validate-bash.sh +301 -4
  10. package/autonomy/lib/cockpit-render.sh +8 -2
  11. package/autonomy/lib/cr-rematerialize.py +101 -4
  12. package/autonomy/lib/deadline.py +957 -0
  13. package/autonomy/lib/dependency-setup.sh +205 -0
  14. package/autonomy/lib/done-recognition.sh +45 -0
  15. package/autonomy/lib/efficiency_cost.py +13 -6
  16. package/autonomy/lib/no_mock_scan.py +793 -0
  17. package/autonomy/lib/prd-enrich.sh +42 -0
  18. package/autonomy/lib/proof-analytics-props.py +69 -0
  19. package/autonomy/lib/proof-generator.py +282 -95
  20. package/autonomy/lib/proof-template.html +15 -12
  21. package/autonomy/lib/proof-verify.py +151 -102
  22. package/autonomy/lib/requirements_contract.py +848 -0
  23. package/autonomy/lib/sdk-mode.sh +103 -0
  24. package/autonomy/lib/secret-scan.sh +139 -0
  25. package/autonomy/lib/spec-expand.sh +208 -0
  26. package/autonomy/lib/tree_digest.py +266 -0
  27. package/autonomy/lib/voter-agents.sh +58 -8
  28. package/autonomy/lib/workspace_diff.py +124 -0
  29. package/autonomy/loki +189 -17
  30. package/autonomy/playwright-verify.sh +501 -0
  31. package/autonomy/prd-checklist.sh +17 -8
  32. package/autonomy/provider-offer.sh +111 -0
  33. package/autonomy/run.sh +4417 -676
  34. package/autonomy/sandbox.sh +42 -0
  35. package/autonomy/spec-interrogation.sh +148 -5
  36. package/autonomy/spec.sh +118 -1
  37. package/autonomy/telemetry.sh +133 -7
  38. package/autonomy/verify.sh +107 -0
  39. package/bin/loki +110 -3
  40. package/completions/_loki +10 -0
  41. package/completions/loki.bash +1 -1
  42. package/dashboard/__init__.py +1 -1
  43. package/dashboard/audit.py +96 -7
  44. package/dashboard/build_supervisor.py +1619 -0
  45. package/dashboard/control.py +8 -0
  46. package/dashboard/prompt_optimizer.py +7 -0
  47. package/dashboard/server.py +577 -22
  48. package/dashboard/static/trust.html +1 -1
  49. package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
  50. package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
  51. package/docs/INSTALLATION.md +19 -2
  52. package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
  53. package/docs/PLANS-INDEX.md +33 -0
  54. package/docs/PRIVACY.md +29 -1
  55. package/docs/SIGNED-RECEIPTS.md +102 -0
  56. package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
  57. package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
  58. package/docs/V8-AGENT-SDK-PLAN.md +692 -0
  59. package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
  60. package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
  61. package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
  62. package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
  63. package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
  64. package/docs/alternative-installations.md +4 -4
  65. package/loki-ts/dist/loki.js +674 -285
  66. package/loki-ts/package.json +2 -0
  67. package/mcp/__init__.py +1 -1
  68. package/package.json +7 -6
  69. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  70. package/providers/claude.sh +75 -0
  71. package/providers/codex.sh +85 -13
  72. package/references/sdk-mode.md +106 -0
  73. package/skills/model-selection.md +1 -1
  74. package/skills/quality-gates.md +22 -0
@@ -671,10 +671,48 @@ cleanup_worktrees() {
671
671
  #===============================================================================
672
672
  # Docker Desktop Sandbox (microVM-based isolation via Docker Desktop 4.58+)
673
673
  #===============================================================================
674
+ #
675
+ # v8.1 DEPRECATION SPLIT (binary-hosting is legacy; isolation is NOT):
676
+ # This path creates a microVM from Docker Desktop's "claude" sandbox TEMPLATE,
677
+ # which bakes the `claude` CLI binary into the VM so the agent can run inside it.
678
+ # With v8's SDK route, the judge/text sites (and, under LOKI_SDK_MODE, the whole
679
+ # engine) run pure-HTTPS with only ANTHROPIC_API_KEY -- NO in-VM `claude` binary
680
+ # is needed. So the *binary-hosting* purpose of this template is obsolete.
681
+ #
682
+ # What is NOT obsolete, and matters MORE with an in-process SDK: isolating
683
+ # untrusted CODE EXECUTION (npm install / build / test / the generated app).
684
+ # In-process SDK judging runs at HOST privilege, so wrapping the code-exec in a
685
+ # container/VM is still the right posture. The forward path is the existing
686
+ # `start_sandbox` container/worktree isolation (the automatic fallback at the
687
+ # `docker sandbox create` failure below); a future release ships a lightweight
688
+ # `bun + SDK` sandbox image with no bundled agent binary.
689
+ #
690
+ # This release: annotation + a one-shot deprecation notice on selection only. No
691
+ # behavior change, no removal. See tests/test-sandbox-deprecation.sh.
674
692
 
675
693
  # Desktop sandbox name follows same pattern as container name
676
694
  DESKTOP_SANDBOX_NAME="$CONTAINER_NAME"
677
695
 
696
+ # Emit a one-shot deprecation notice for the microVM binary-hosting path. Names
697
+ # the binary-hosting only and states explicitly that ISOLATION is retained (so it
698
+ # is never misread as "the sandbox is going away"). Best-effort + non-blocking:
699
+ # telemetry must never abort a sandbox start. Mirrors the cli_command_deprecated
700
+ # pattern (event type/source/action + a human log line). Opt out with
701
+ # LOKI_SANDBOX_DEPRECATION_QUIET=1.
702
+ _desktop_deprecation_notice() {
703
+ [ "${LOKI_SANDBOX_DEPRECATION_QUIET:-0}" = "1" ] && return 0
704
+ log_warn "DEPRECATED: the Docker Desktop microVM 'claude' template hosts the"
705
+ log_warn " claude BINARY in-VM, which the v8 SDK route no longer needs."
706
+ log_warn " Code-execution ISOLATION is RETAINED via the container/worktree"
707
+ log_warn " sandbox (unchanged); only the binary-hosting template is legacy."
708
+ # Best-effort telemetry (never abort the start on a telemetry miss).
709
+ if [ -f "$SKILL_DIR/events/emit.sh" ]; then
710
+ bash "$SKILL_DIR/events/emit.sh" sandbox cli sandbox_microvm_binary_deprecated \
711
+ isolation_retained=true path=docker-desktop >/dev/null 2>&1 || true
712
+ fi
713
+ return 0
714
+ }
715
+
678
716
  # Install provider CLI inside sandbox if not pre-installed
679
717
  _desktop_install_provider_cli() {
680
718
  local sandbox_name="$1" provider="$2"
@@ -770,6 +808,10 @@ start_docker_desktop_sandbox() {
770
808
  validate_project_dir || return 1
771
809
  warn_missing_api_keys "$provider"
772
810
 
811
+ # v8.1: one-shot deprecation notice for the microVM binary-hosting path
812
+ # (isolation is retained; only the bundled-binary template is legacy).
813
+ _desktop_deprecation_notice
814
+
773
815
  # Check if sandbox already exists
774
816
  local sandbox_exists=false
775
817
  if docker sandbox ls 2>/dev/null | grep -q "$DESKTOP_SANDBOX_NAME"; then
@@ -142,16 +142,94 @@ spec_interrogation_class_for() {
142
142
  lc_section="$(printf '%s' "$section" | tr '[:upper:]' '[:lower:]')"
143
143
  lc_line="$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')"
144
144
 
145
+ # AUTHORITATIVE SECTION GUARD (first): the grill CATEGORIZES each finding into
146
+ # a section, and that categorization is the strongest signal of the finding's
147
+ # KIND. A finding the grill itself filed under an explicitly non-contradiction
148
+ # section -- Ambiguities, (Unstated) Assumptions, Open questions, Blind spots,
149
+ # Missing/gaps -- is that KIND, even if its prose happens to use the word
150
+ # "contradict"/"conflict" while POSING the ambiguity ("two implementations
151
+ # could show contradictory trends", "a dev-only build contradicts opening
152
+ # index.html"). Those are assumable-away ambiguities, not spec-internal
153
+ # "X and not-X". Classify by the grill's own section here so a stray keyword
154
+ # in the finding prose can never escalate an ambiguity to a no-retry-terminal
155
+ # contradiction. Opt out with LOKI_SPEC_SECTION_KIND_GUARD=0.
156
+ if [ "${LOKI_SPEC_SECTION_KIND_GUARD:-1}" = "1" ]; then
157
+ case "$lc_section" in
158
+ *ambigu*|*acceptance*) printf 'ambiguous'; return 0 ;;
159
+ *unstated\ assumption*|*assumption*) printf 'underspecified'; return 0 ;;
160
+ *open\ question*) printf 'ambiguous'; return 0 ;;
161
+ *blind\ spot*|*security*|*scale*|*reliability*) printf 'missing'; return 0 ;;
162
+ esac
163
+ fi
164
+
165
+ # A contradiction keyword classifies the finding as contradictory ONLY when
166
+ # the finding ASSERTS a contradiction ("section 2 says X, section 5 says
167
+ # not-X"), NOT when it merely QUESTIONS a hypothetical one ("what stops the
168
+ # KPIs from contradicting the chart?", "these could conflict"). A grill that
169
+ # ASKS about a possible inconsistency is raising an AMBIGUITY (assumable-away
170
+ # with a default: "make them consistent"), not reporting an unresolvable
171
+ # spec-internal "X and not-X". Naive substring matching force-tagged such
172
+ # questions as no-retry-terminal contradictions and killed valid specs.
173
+ # Interrogative / hypothetical / risk framings demote the keyword to a normal
174
+ # (assumable) finding; the section-based classification below then applies.
175
+ # Opt back into the old broad substring match with
176
+ # LOKI_SPEC_CONTRADICTION_KEYWORD_STRICT=0.
145
177
  case "$lc_line" in
146
- *contradict*|*conflict*|*inconsistent*|*mutually\ exclusive*)
147
- printf 'contradictory'; return 0 ;;
178
+ *contradict*|*conflict*|*inconsistent*|*incompatible*|*mutually\ exclusive*)
179
+ if [ "${LOKI_SPEC_CONTRADICTION_KEYWORD_STRICT:-1}" = "1" ]; then
180
+ case "$lc_line" in
181
+ # Interrogative or hypothetical -> ambiguity, not a contradiction.
182
+ what\ stops*|what\ if*|what\ prevents*|how\ do\ we\ ensure*|\
183
+ *could\ contradict*|*could\ conflict*|*might\ contradict*|*might\ conflict*|\
184
+ *may\ contradict*|*may\ conflict*|*risk\ of\ *|*potential*|\
185
+ *would\ contradict*|*if\ they\ contradict*|*from\ contradicting*|\
186
+ *stops\ them\ from*|*prevents\ them\ from*|*avoid*|\
187
+ *unclear\ whether*|*not\ specified*|*no\ guarantee*|*to\ ensure*)
188
+ : ;; # demote: fall through to section-based classification
189
+ *)
190
+ printf 'contradictory'; return 0 ;;
191
+ esac
192
+ else
193
+ printf 'contradictory'; return 0
194
+ fi
195
+ ;;
148
196
  esac
149
197
 
150
- # P2-4: a "Contradictions" SECTION tags its findings contradictory even when
151
- # the line carries no contradiction keyword (see severity_for for rationale).
198
+ # P2-4 REFINED (false-positive fix): the grill's report sections are free-form
199
+ # LLM text. A finding under a PURE contradictions heading ("Contradictions",
200
+ # "Contradictory requirements") is the grill's authoritative "X and not-X"
201
+ # categorization -- trust it, even when the conflict is stated SEMANTICALLY with
202
+ # no magic word ("immutable records" vs "an edit endpoint"). Recording a
203
+ # contradiction is non-fatal by default now (LOKI_SPEC_CONTRADICTION_FASTFAIL=0
204
+ # in run.sh proceeds with the finding; the completion gate still blocks
205
+ # fake-green), so trusting a clean heading costs a recorded finding, not a
206
+ # terminal kill.
207
+ # The false positives lived in MIXED headings that lump unlike findings
208
+ # ("Contradictions and ambiguities", "Contradictions / open questions") -- there
209
+ # a bare ambiguity (assumable away with an implementer default) was mislabeled a
210
+ # contradiction. For a mixed heading we still require corroborating conflict
211
+ # language in the line itself. The line-level check above already catches
212
+ # genuinely-worded contradictions regardless of section.
213
+ # LOKI_SPEC_SECTION_CONTRADICTION_FORCE=1 forces the old broad behavior (any
214
+ # contradiction-ish heading -> contradictory).
152
215
  case "$lc_section" in
153
216
  *contradiction*|*contradictor*)
154
- printf 'contradictory'; return 0 ;;
217
+ # Mixed heading: co-mentions ambiguity/question/assumption/gap words ->
218
+ # the finding could be either kind, so demand line evidence.
219
+ local _mixed=0
220
+ case "$lc_section" in
221
+ *ambigu*|*question*|*assumption*|*gap*|*missing*|*unclear*|*open*|*blind*)
222
+ _mixed=1 ;;
223
+ esac
224
+ if [ "${LOKI_SPEC_SECTION_CONTRADICTION_FORCE:-0}" = "1" ] || [ "$_mixed" = "0" ]; then
225
+ printf 'contradictory'; return 0
226
+ fi
227
+ # mixed heading: require corroborating conflict language in the line
228
+ case "$lc_line" in
229
+ *contradict*|*conflict*|*inconsistent*|*incompatible*|*cannot\ both*|*mutually\ exclusive*|*versus*|*but\ also*)
230
+ printf 'contradictory'; return 0 ;;
231
+ esac
232
+ ;;
155
233
  esac
156
234
 
157
235
  case "$lc_section" in
@@ -823,6 +901,71 @@ for t in titles:
823
901
  }
824
902
 
825
903
  # Total ledger entries + high count, "total high" on one line. For summaries.
904
+ # ---------------------------------------------------------------------------
905
+ # spec_contradiction_confident: confirm a contradiction verdict is NOT a flaky
906
+ # single-LLM-sample false positive before it is allowed to trip a no-retry
907
+ # terminal exit.
908
+ #
909
+ # WHY: the contradiction verdict comes from ONE Devil's-Advocate grill sample
910
+ # (grill_main is invoked once per run). An LLM judge is non-deterministic, so the
911
+ # SAME spec is judged "contradictory" only some fraction of runs -- measured
912
+ # ~1/3 in the v8 benchmark. Escalating that lone sample straight to exit 20
913
+ # ("terminal failure, no retry", whose own contract asserts "re-running on the
914
+ # same inputs fails the same way") is unsound: re-running an LLM judge does NOT
915
+ # fail the same way. This helper turns a single sample into an N-of-M agreement:
916
+ # it re-grills up to (MIN_SAMPLES - 1) more times and returns confident (0) only
917
+ # if the contradiction PERSISTS across the required number of independent
918
+ # samples. A verdict that does not reproduce is treated as flaky -> not
919
+ # confident (non-zero) -> the caller must NOT terminal-exit; it falls through to
920
+ # the in-loop resolve-with-default recovery (spec_ledger_acknowledge_all).
921
+ #
922
+ # This is a general "confirm before an irreversible terminal action on a
923
+ # non-deterministic verdict" pattern, not a fix for any one spec. It is a no-op
924
+ # (returns confident immediately) when MIN_SAMPLES<=1 (LOKI_SPEC_CONTRADICTION_MIN_SAMPLES=1
925
+ # restores the pre-fix single-sample behavior), or when re-grilling is impossible
926
+ # (no provider / grill unavailable) -- in that degraded case we KEEP the original
927
+ # single-sample verdict rather than silently clearing it (fail-closed on the
928
+ # original signal, never fabricate a clear).
929
+ #
930
+ # Usage: spec_contradiction_confident <spec_path>
931
+ # exit 0 -> contradiction confirmed across samples (safe to terminal-fail)
932
+ # exit 1 -> not confirmed / flaky (caller must recover-before-fail, not exit 20)
933
+ # ---------------------------------------------------------------------------
934
+ spec_contradiction_confident() {
935
+ local spec_path="${1:-}"
936
+ local min_samples="${LOKI_SPEC_CONTRADICTION_MIN_SAMPLES:-2}"
937
+ case "$min_samples" in ''|*[!0-9]*) min_samples=2 ;; esac
938
+
939
+ # MIN_SAMPLES<=1: legacy single-sample behavior -> the current (already >=1)
940
+ # verdict is authoritative. Confident.
941
+ [ "$min_samples" -le 1 ] && return 0
942
+
943
+ # Re-grill up to (min_samples - 1) additional times; every re-sample must ALSO
944
+ # report >=1 unresolved contradiction for the verdict to be confident.
945
+ # We already hold sample #1 (the caller only invokes us when count>=1).
946
+ local needed=$(( min_samples - 1 ))
947
+ local i
948
+ for (( i = 1; i <= needed; i++ )); do
949
+ # A fresh grill sample. spec_interrogation_run re-runs grill_main and
950
+ # re-classifies into the ledger. If re-grilling is not possible (no
951
+ # provider), the count will not change and we must not clear a real
952
+ # signal -- so treat an unchanged/failed re-sample as still-present
953
+ # (fail-closed on the original verdict).
954
+ if type spec_interrogation_run &>/dev/null; then
955
+ spec_interrogation_run "$spec_path" >/dev/null 2>&1 || true
956
+ fi
957
+ local _n
958
+ _n="$(spec_ledger_contradiction_unresolved_count 2>/dev/null | head -1)"
959
+ case "$_n" in ''|*[!0-9]*) _n=0 ;; esac
960
+ if [ "$_n" -lt 1 ]; then
961
+ # This independent sample does NOT see the contradiction -> flaky.
962
+ return 1
963
+ fi
964
+ done
965
+ # Persisted across all required samples -> confident it is a real contradiction.
966
+ return 0
967
+ }
968
+
826
969
  spec_ledger_counts() {
827
970
  local dir
828
971
  dir="$(_spec_ledger_dir)"
package/autonomy/spec.sh CHANGED
@@ -91,7 +91,13 @@ spec_resolve_source() {
91
91
  ".loki/generated-prd.md" \
92
92
  "prd.md" \
93
93
  "PRD.md" \
94
- "docs/prd.md"; do
94
+ "docs/prd.md" \
95
+ "openapi.yaml" \
96
+ "openapi.yml" \
97
+ "openapi.json" \
98
+ "api/openapi.yaml" \
99
+ "api/openapi.json" \
100
+ "schema.graphql"; do
95
101
  if [ -f "$candidate" ]; then
96
102
  printf '%s\n' "$candidate"
97
103
  return 0
@@ -121,6 +127,117 @@ except OSError as exc:
121
127
 
122
128
  lines = raw.splitlines()
123
129
 
130
+ # ---------------------------------------------------------------------------
131
+ # Contract fast path (RUN-25 iter 25): when the spec IS an OpenAPI / GraphQL /
132
+ # Postman contract, lock one requirement PER OPERATION with a per-operation
133
+ # content hash, so `spec status` reports the exact operation id that DRIFTED
134
+ # (a response schema edit on /users flags "users.get", not the whole file).
135
+ # Falls through to the markdown heading/checklist logic below on anything else,
136
+ # byte-identical to the prior behavior for a normal PRD.
137
+ # ---------------------------------------------------------------------------
138
+ def _contract_requirements(text):
139
+ def load_structured(t):
140
+ try:
141
+ return json.loads(t)
142
+ except Exception:
143
+ pass
144
+ try:
145
+ import yaml
146
+ return yaml.safe_load(t)
147
+ except Exception:
148
+ return None
149
+
150
+ def op_hash(payload):
151
+ # Stable hash of the operation's own definition (order-insensitive for
152
+ # dict payloads via sort_keys), so a change to THIS op flips only its id.
153
+ try:
154
+ blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
155
+ except Exception:
156
+ blob = repr(payload)
157
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
158
+
159
+ data = load_structured(text)
160
+ ops = []
161
+
162
+ # OpenAPI / Swagger
163
+ if isinstance(data, dict) and ("openapi" in data or "swagger" in data) and isinstance(data.get("paths"), dict):
164
+ for p, item in data["paths"].items():
165
+ if not isinstance(item, dict):
166
+ continue
167
+ for method, op in item.items():
168
+ if method.lower() not in ("get", "post", "put", "patch", "delete", "head", "options", "trace"):
169
+ continue
170
+ if not isinstance(op, dict):
171
+ continue
172
+ opid = op.get("operationId") or ("%s %s" % (method.upper(), p))
173
+ ops.append((str(opid).strip(), "operation", op_hash(op)))
174
+ return ops
175
+
176
+ # Postman collection
177
+ if isinstance(data, dict) and isinstance(data.get("info"), dict) and "item" in data:
178
+ def walk(items, prefix=""):
179
+ for it in items or []:
180
+ if not isinstance(it, dict):
181
+ continue
182
+ name = it.get("name", "request")
183
+ if isinstance(it.get("item"), list):
184
+ walk(it["item"], prefix + name + " / ")
185
+ elif "request" in it:
186
+ label = (prefix + name).strip()
187
+ ops.append((label, "request", op_hash(it.get("request"))))
188
+ walk(data.get("item"))
189
+ return ops
190
+
191
+ # GraphQL SDL (text)
192
+ if re.search(r"\btype\s+(Query|Mutation|Subscription)\b", text):
193
+ for root in ("Query", "Mutation", "Subscription"):
194
+ m = re.search(r"\btype\s+" + root + r"\b[^{]*\{", text)
195
+ if not m:
196
+ continue
197
+ i = m.end()
198
+ depth = 1
199
+ body_start = i
200
+ while i < len(text) and depth > 0:
201
+ if text[i] == "{":
202
+ depth += 1
203
+ elif text[i] == "}":
204
+ depth -= 1
205
+ i += 1
206
+ body = text[body_start:i - 1]
207
+ for fm in re.finditer(r"^\s*([A-Za-z_]\w*)\s*(\([^)]*\))?\s*:\s*([^\n#]+)", body, re.M):
208
+ field = fm.group(1)
209
+ sig = fm.group(0).strip()
210
+ ops.append(("%s.%s" % (root, field), "field", hashlib.sha256(sig.encode("utf-8")).hexdigest()))
211
+ return ops
212
+
213
+ return []
214
+
215
+
216
+ _contract_ops = _contract_requirements(raw)
217
+ if _contract_ops:
218
+ _out = []
219
+ _seen = {}
220
+ for _name, _kind, _h in _contract_ops:
221
+ base = _name.strip().lower() or "op"
222
+ base = re.sub(r"\s+", " ", base)
223
+ if base in _seen:
224
+ _seen[base] += 1
225
+ rid = "%s#%d" % (base, _seen[base])
226
+ else:
227
+ _seen[base] = 0
228
+ rid = base
229
+ _out.append({
230
+ "id": rid,
231
+ "kind": _kind,
232
+ "level": 100,
233
+ "text": _name,
234
+ "line": 0,
235
+ "content_hash": _h,
236
+ })
237
+ json.dump({"requirements": _out}, sys.stdout, indent=2)
238
+ sys.stdout.write("\n")
239
+ sys.exit(0)
240
+
124
241
  heading_re = re.compile(r'^(#{1,6})\s+(.*\S)\s*$')
125
242
  # Checklist item: optional leading whitespace, a bullet, then [ ] / [x] / [X].
126
243
  checklist_re = re.compile(r'^\s*[-*]\s+\[([ xX])\]\s+(.*\S)\s*$')
@@ -146,6 +146,47 @@ _loki_detect_channel() {
146
146
  esac
147
147
  }
148
148
 
149
+ # _loki_known_command <token>: FIXED ALLOWLIST at the telemetry boundary. Prints
150
+ # the token verbatim when it is a real loki subcommand, else the literal "other".
151
+ #
152
+ # WHY THIS EXISTS. Every command-shaped telemetry value is the user's raw first
153
+ # CLI token, and loki_telemetry's payload builder is a PASS-THROUGH dict, not an
154
+ # allowlist. So the honest typo of dropping the subcommand off this project's own
155
+ # documented quickstart --
156
+ # loki ./client-acme-merger-prd.md
157
+ # -- put a user-authored file name on the wire verbatim. Spec file names routinely
158
+ # carry client and codename. Sanitizing at this boundary bounds the value space to
159
+ # a fixed, public vocabulary by construction, so no path, flag value, or spec name
160
+ # can reach PostHog through a command field regardless of what the user typed.
161
+ #
162
+ # The arms below mirror autonomy/loki's own `case "$command" in` dispatch (whose
163
+ # final `*)` arm is literally this same unknown bucket), UNIONed with the
164
+ # bin/loki Bun-route arm (`internal` is Bun-only) and the `report` pre-route.
165
+ # A token missing from this list degrades to "other": lossy, never leaky, so a
166
+ # stale list after a new subcommand lands is safe by construction.
167
+ _loki_known_command() {
168
+ case "${1:-}" in
169
+ --help|--version|-h|-v|agent|analyze|api|assets\
170
+ |audit|bench|checkpoint|ci|cleanup|cluster|cockpit|code\
171
+ |completions|compliance|compound|config|context|cost|council|cp\
172
+ |crash|ctx|dashboard|demo|deploy|docker|docs|doctor\
173
+ |dogfood|enterprise|explain|export|failover|github|grill|handoff\
174
+ |heal|help|import|init|internal|issue|kpis|logs\
175
+ |magic|mcp|memory|metrics|migrate|modernize|monitor|next\
176
+ |notify|onboard|open|optimize|otel|own|pause|plan\
177
+ |preview|projects|proof|provider|quick|quickstart|rc|receipt\
178
+ |remote|report|reset|resume|review|rollback|run|sandbox\
179
+ |secrets|secure|self-update|self_update|sentrux|serve|setup-skill|share\
180
+ |ship|spec|start|state|stats|status|steer|stop\
181
+ |syslog|telemetry|template|test|tour|trigger|trust|trust-metrics\
182
+ |ultracode|update|verify|version|voice|watch|watchdog|web\
183
+ |welcome|why|wiki|worktree|wt)
184
+ printf '%s' "$1" ;;
185
+ *)
186
+ printf 'other' ;;
187
+ esac
188
+ }
189
+
149
190
  loki_telemetry() {
150
191
  _loki_telemetry_enabled || return 0
151
192
  local event="$1"; shift
@@ -160,12 +201,16 @@ loki_telemetry() {
160
201
  os_name=$(uname -s 2>/dev/null || echo "unknown")
161
202
  arch=$(uname -m 2>/dev/null || echo "unknown")
162
203
 
163
- # Build JSON payload safely using Python to prevent injection
164
- local extra_args=""
165
- for arg in "$@"; do
166
- extra_args="${extra_args}${extra_args:+ }${arg}"
167
- done
168
-
204
+ # Build JSON payload safely using Python to prevent injection.
205
+ # The extra key=value pairs are forwarded as "$@" -- one argv slot per pair,
206
+ # QUOTED. They used to be joined into a single space-delimited string and
207
+ # then re-split by an unquoted expansion, which broke two ways: a value
208
+ # containing a space was truncated (`prd=my file.md` -> `prd=my`, rest
209
+ # dropped) or, if a fragment itself contained `=`, forged an extra PostHog
210
+ # property KEY; and the unquoted expansion also globbed, so `spec=*.md`
211
+ # expanded against the cwd and injected file names into the payload. Callers
212
+ # already pass each pair as its own quoted word, so "$@" is the array and no
213
+ # caller signature changes.
169
214
  local payload
170
215
  payload=$(python3 -c "
171
216
  import json, sys
@@ -175,7 +220,7 @@ for arg in sys.argv[5:]:
175
220
  k, v = arg.split('=', 1)
176
221
  props[k] = v
177
222
  print(json.dumps({'api_key': '$LOKI_POSTHOG_KEY', 'event': sys.argv[5] if len(sys.argv) > 5 else '', 'distinct_id': '$distinct_id', 'properties': props}))
178
- " "$os_name" "$arch" "$version" "$channel" $extra_args 2>/dev/null) || return 0
223
+ " "$os_name" "$arch" "$version" "$channel" "$@" 2>/dev/null) || return 0
179
224
  # Re-inject event and distinct_id properly
180
225
  payload=$(python3 -c "
181
226
  import json, sys
@@ -190,3 +235,84 @@ print(json.dumps(d))
190
235
  -d "$payload" >/dev/null 2>&1 &) 2>/dev/null
191
236
  return 0
192
237
  }
238
+
239
+ # _loki_analytics_enabled: STRICTER, second-layer gate for opt-in build-outcome
240
+ # analytics. Sits BELOW the telemetry gate: build_verified fires ONLY when
241
+ # 1. telemetry is enabled at all (_loki_telemetry_enabled -- so every opt-out
242
+ # that kills telemetry also kills analytics), AND
243
+ # 2. the user EXPLICITLY turned analytics on (LOKI_ANALYTICS/LOKI_POSTHOG=on or
244
+ # ~/.loki/config: ANALYTICS_ENABLED=true).
245
+ # Default OFF even for diagnostics-on users: anonymous crash diagnostics are one
246
+ # thing, per-build outcome metrics are a separate, explicit consent. Zero-egress-
247
+ # by-default is a moat, so this stays strictly opt-in. Only already-computed proof
248
+ # scalars are ever sent (see _loki_proof_analytics_props) -- never code, spec text,
249
+ # paths, or file names.
250
+ _loki_analytics_enabled() {
251
+ _loki_telemetry_enabled || return 1
252
+ local _lower
253
+ _lower="$(printf '%s' "${LOKI_ANALYTICS:-${LOKI_POSTHOG:-}}" | tr '[:upper:]' '[:lower:]')"
254
+ [ "$_lower" = "on" ] && return 0
255
+ [ "$_lower" = "1" ] && return 0
256
+ if [ -f "${HOME}/.loki/config" ] && grep -q "^ANALYTICS_ENABLED=true" "${HOME}/.loki/config" 2>/dev/null; then
257
+ return 0
258
+ fi
259
+ return 1
260
+ }
261
+
262
+ # loki_emit_funnel_once <marker-name> <event> [key=value...]: fire ONE
263
+ # first-run-funnel event, at most once per machine, under the SAME strict double
264
+ # gate as build_verified (telemetry enabled AND analytics explicitly opted in --
265
+ # default OFF even for diagnostics-on users). No new consent knob, no new
266
+ # endpoint: it reuses loki_telemetry, so every existing opt-out kills it too.
267
+ #
268
+ # ORDERING IS LOAD-BEARING. Disclosure runs before the emit, and the marker is
269
+ # written only AFTER the gate has passed and we have actually emitted. The
270
+ # reverted attempt burned its marker unconditionally, which meant a first run
271
+ # under CI/auto-off consumed the one-shot and permanently silenced the event for
272
+ # that user. Mirrors _loki_disclose_telemetry_once: act, then mark.
273
+ #
274
+ # Values must already be bounded (see _loki_known_command). Best-effort: every
275
+ # step returns 0, nothing blocks, nothing throws.
276
+ loki_emit_funnel_once() {
277
+ local _marker_name="$1"; shift
278
+ local _event="$1"; shift
279
+ _loki_analytics_enabled || return 0
280
+ local _mfile="${HOME}/.loki/funnel-${_marker_name}"
281
+ [ -f "$_mfile" ] 2>/dev/null && return 0
282
+ if declare -f _loki_disclose_telemetry_once >/dev/null 2>&1; then
283
+ _loki_disclose_telemetry_once
284
+ fi
285
+ loki_telemetry "$_event" "$@" 2>/dev/null || true
286
+ mkdir -p "${HOME}/.loki" 2>/dev/null || return 0
287
+ : > "$_mfile" 2>/dev/null || true
288
+ return 0
289
+ }
290
+
291
+ # loki_emit_build_verified <proof.json path>: fire ONE build_verified event
292
+ # carrying a FIXED ALLOWLIST of already-computed scalars from a finished
293
+ # proof.json. Trust-core untouched -- reads the receipt after it is written,
294
+ # never influences the verdict. Fires only under the strict analytics gate.
295
+ # The allowlist is enforced in Python (below); anything not named is never read,
296
+ # so a schema change cannot silently start leaking a new field. Free-text-shaped
297
+ # fields are excluded on purpose: headline is a bounded enum (VERIFIED / VERIFIED
298
+ # WITH GAPS / NOT VERIFIED), never spec/project text; files_changed is a COUNT,
299
+ # never paths.
300
+ loki_emit_build_verified() {
301
+ _loki_analytics_enabled || return 0
302
+ local _proof="$1"
303
+ [ -f "$_proof" ] || return 0
304
+ command -v python3 >/dev/null 2>&1 || return 0
305
+ local _reader="${SCRIPT_DIR:-${SKILL_DIR:-}}/lib/proof-analytics-props.py"
306
+ [ -f "$_reader" ] || return 0
307
+ local _props
308
+ _props=$(python3 "$_reader" "$_proof" 2>/dev/null) || return 0
309
+ [ -n "$_props" ] || return 0
310
+ # Feed the allowlisted key=value pairs into loki_telemetry (which re-checks
311
+ # the base gate and owns the injection-safe payload build).
312
+ local _args=()
313
+ while IFS= read -r _line; do
314
+ [ -n "$_line" ] && _args+=("$_line")
315
+ done <<< "$_props"
316
+ loki_telemetry "build_verified" "${_args[@]}"
317
+ return 0
318
+ }
@@ -726,6 +726,91 @@ verify_gate_static() {
726
726
  return 0
727
727
  }
728
728
 
729
+ # ---------------------------------------------------------------------------
730
+ # Gate: NO-MOCK data render (Proof-of-Function, static half).
731
+ #
732
+ # WHAT IS SCANNED:
733
+ # The changed-file union identifies the trust boundary. The shared scanner may
734
+ # follow a named relative import into an unchanged source module, but it only
735
+ # blocks when either the render module or backing declaration changed. A
736
+ # rendered operational collection backed by an inline mock array, faker, or
737
+ # placeholder literal is the disproof: a fake app that only looks done.
738
+ #
739
+ # ARTIFACT:
740
+ # Writes .loki/verification/nomock-scan.json as an audit receipt. Completion
741
+ # never trusts this mutable file and independently reruns the engine-owned
742
+ # scanner over current source. On a hit, verify also emits a High finding so
743
+ # `loki verify` blocks. This is an observation of shipped source, not a guess.
744
+ #
745
+ # FAIL-CLOSED / FALSE-POSITIVE TUNING:
746
+ # Binds the rendered symbol to its declaration and exempts static content such
747
+ # as features, pricing, comparisons, and stories. Excludes test, story, mock,
748
+ # fixture, MSW, generated, and declaration paths. High-confidence only;
749
+ # anything else passes or skips to avoid blocking design-system demos, empty
750
+ # states, or seed files.
751
+ # ---------------------------------------------------------------------------
752
+ verify_gate_nomock() {
753
+ local tree="$1"
754
+ local changed="$VERIFY_DIFF_NAMES"
755
+ local PYTHONPATH="" PYTHONNOUSERSITE=1
756
+ export PYTHONPATH PYTHONNOUSERSITE
757
+
758
+ if [ "${LOKI_PROOF_NOMOCK:-1}" = "0" ]; then
759
+ _verify_add_gate "nomock" "skipped" "" "nomock gate disabled (LOKI_PROOF_NOMOCK=0)" "true"
760
+ return 0
761
+ fi
762
+ if [ -z "$changed" ]; then
763
+ _verify_add_gate "nomock" "skipped" "" "no changed files in diff" "true"
764
+ return 0
765
+ fi
766
+ if ! command -v python3 >/dev/null 2>&1; then
767
+ _verify_add_gate "nomock" "inconclusive" "" "python3 unavailable" "true"
768
+ return 0
769
+ fi
770
+
771
+ local scan_dir=".loki/verification"
772
+ mkdir -p "$scan_dir" 2>/dev/null || true
773
+ local scan_file="$scan_dir/nomock-scan.json"
774
+
775
+ # The scan runs in $tree over the changed files, writes the receipt JSON, and
776
+ # prints its status line (FAIL:<file>:<line>:<snippet> | PASS:<n> | SKIP:0).
777
+ local scanner
778
+ scanner="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/no_mock_scan.py"
779
+ if [ ! -f "$scanner" ]; then
780
+ _verify_add_gate "nomock" "inconclusive" "" "no-mock scanner unavailable" "true"
781
+ return 0
782
+ fi
783
+ local status_line
784
+ status_line=$(
785
+ _NM_FILES="$changed" \
786
+ _NM_TREE="$tree" \
787
+ _NM_OUT="$scan_file" \
788
+ _NM_BASE="${VERIFY_MERGE_BASE:-}" \
789
+ python3 -I "$scanner" 2>/dev/null \
790
+ || echo "INCONCLUSIVE:detector_error"
791
+ )
792
+
793
+ case "$status_line" in
794
+ FAIL:*)
795
+ local rest="${status_line#FAIL:}"
796
+ local hit_file="${rest%%:*}"
797
+ _verify_add_gate "nomock" "fail" "static-scan" "rendered operational collection resolves to inline, faker, or placeholder-backed data: $rest" "true"
798
+ _verify_add_finding "High" "functionality" "deterministic:nomock-scan" "$hit_file" "null" \
799
+ "Mock-backed data render: a list, table, or dashboard in $hit_file resolves to an inline mock array, faker value, or placeholder literal. Wire the rendered collection to a real data source. ($rest)"
800
+ ;;
801
+ PASS:*)
802
+ _verify_add_gate "nomock" "pass" "static-scan" "${status_line#PASS:} UI module(s) scanned, no high-confidence rendered operational mock detected" "true"
803
+ ;;
804
+ SKIP:*)
805
+ _verify_add_gate "nomock" "skipped" "" "no scannable UI/data-render files in diff" "true"
806
+ ;;
807
+ *)
808
+ _verify_add_gate "nomock" "inconclusive" "" "scanner error" "true"
809
+ ;;
810
+ esac
811
+ return 0
812
+ }
813
+
729
814
  # ---------------------------------------------------------------------------
730
815
  # Gate: secret scan (NET-NEW).
731
816
  #
@@ -780,6 +865,27 @@ verify_gate_static() {
780
865
  verify_secret_scan_file() {
781
866
  local file="$1"
782
867
 
868
+ # TEMPLATE / FIXTURE / TEST PATHS: a file whose name declares it is an
869
+ # example, or that exists to TEST the secret scanner, is not a leak.
870
+ # Kept byte-identical in intent to the same guard in
871
+ # autonomy/lib/secret-scan.sh, because these two matchers are duplicates and
872
+ # a fix applied to only one of them is a fix that does not hold.
873
+ #
874
+ # Concretely: tests/test-secret-gate-false-positives.sh has to contain
875
+ # realistic-looking keys to prove the gate still catches real ones. Without
876
+ # this, adding a test for the scanner FAILS the scanner -- and the CI gate
877
+ # reported exactly that, two CRITICAL "hardcoded secret" findings pointing
878
+ # at deliberately fake fixtures.
879
+ #
880
+ # Scoped to paths that ANNOUNCE themselves. A real secret in a real source
881
+ # path is still caught by both tiers below.
882
+ case "$file" in
883
+ *.example|*.example.*|*.sample|*.sample.*|*.template|*.template.*|*.dist)
884
+ return 1 ;;
885
+ */fixtures/*|*/__fixtures__/*|*/tests/*|*/test/*|*test-*.sh|*.test.*|*_test.*)
886
+ return 1 ;;
887
+ esac
888
+
783
889
  # TIER 1: specific formats. No deny filter -- a format match is a finding.
784
890
  local tier1=(
785
891
  'AKIA[0-9A-Z]{16}' # AWS access key id
@@ -2657,6 +2763,7 @@ verify_main() {
2657
2763
  verify_gate_build "$tree"
2658
2764
  verify_gate_tests "$tree"
2659
2765
  verify_gate_static "$tree"
2766
+ verify_gate_nomock "$tree"
2660
2767
  verify_gate_secret_scan "$tree"
2661
2768
  verify_gate_dependency_audit "$tree"
2662
2769
  # Runtime boot smoke (NET-NEW). Self-suppresses (no gate row) when no