loki-mode 7.49.0 → 7.50.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/autonomy/run.sh CHANGED
@@ -7038,6 +7038,130 @@ enforce_static_analysis() {
7038
7038
  }
7039
7039
  fi
7040
7040
 
7041
+ # C / C++ (P1-6: cppcheck is a standalone static analyzer that needs no
7042
+ # build system, headers, or compile flags, so it does not false-block on
7043
+ # missing includes the way a per-file `clang` compile would. The exit gate
7044
+ # fires only on `error` severity; style/warning/portability findings on WIP
7045
+ # code do not block. When cppcheck is absent we pass through honestly
7046
+ # (log, no block) rather than silently skipping.)
7047
+ local cfiles
7048
+ cfiles=$(echo "$changed_files" | grep -E '\.(c|cc|cpp|cxx|h|hpp|hxx)$' || true)
7049
+ if [ -n "$cfiles" ]; then
7050
+ local cabs=""
7051
+ for f in $cfiles; do
7052
+ [ -f "${TARGET_DIR:-.}/$f" ] && cabs="$cabs ${TARGET_DIR:-.}/$f"
7053
+ done
7054
+ if [ -n "$cabs" ]; then
7055
+ if command -v cppcheck &>/dev/null; then
7056
+ total_checked=$((total_checked + $(echo "$cabs" | wc -w)))
7057
+ # Default cppcheck reports ONLY error severity, so with
7058
+ # --error-exitcode=2 the gate returns 2 exclusively on an
7059
+ # error-severity finding. We deliberately do NOT pass
7060
+ # --enable=warning: that would make warning/style/portability
7061
+ # findings on incomplete WIP code block the iteration (verified:
7062
+ # a deref-then-null-check warning returns 2 under --enable=warning
7063
+ # but 0 under the default ruleset). Error severity only = honest
7064
+ # parity with the TS/shell `-S error` gates above.
7065
+ local cpp_out cpp_rc=0
7066
+ # shellcheck disable=SC2086
7067
+ cpp_out=$(cppcheck --quiet --error-exitcode=2 $cabs 2>&1) || cpp_rc=$?
7068
+ if [ "$cpp_rc" -eq 2 ]; then
7069
+ findings=$((findings + 1))
7070
+ details="${details}cppcheck (error severity): $(echo "$cpp_out" | tail -3 | tr '\n' ' '). "
7071
+ fi
7072
+ else
7073
+ log_info "Static analysis: cppcheck not on PATH, skipping C/C++ check (pass-through)"
7074
+ fi
7075
+ fi
7076
+ fi
7077
+
7078
+ # Kotlin (P1-6: ktlint and detekt are standalone, build-system-free linters.
7079
+ # Prefer ktlint; fall back to detekt. Absent -> honest pass-through.)
7080
+ #
7081
+ # ADVISORY ONLY (not blocking): unlike cppcheck/checkstyle which expose an
7082
+ # error-vs-style severity distinction, ktlint is a pure formatter -- every
7083
+ # finding it reports is a style/formatting issue and it exits nonzero on ANY
7084
+ # violation, with no CLI mode to fail only on error severity. detekt's failure
7085
+ # threshold is config-driven (maxIssues) and its findings are code smells, not
7086
+ # compiler errors; there is no stable CLI flag to fail only on error severity.
7087
+ # Per the gate principle (a new-language arm must NOT block on style/formatting,
7088
+ # consistent with cppcheck's error-exitcode-only and the JS/TS/Py `-S error`
7089
+ # gates), we run these linters as ADVISORY: report findings via log_warn and
7090
+ # the details string, but do NOT increment `findings` (no BLOCK). This avoids
7091
+ # false-blocking a WIP build on formatting. Absent -> honest pass-through.
7092
+ local kt_files
7093
+ kt_files=$(echo "$changed_files" | grep -E '\.(kt|kts)$' || true)
7094
+ if [ -n "$kt_files" ]; then
7095
+ local kt_abs=""
7096
+ for f in $kt_files; do
7097
+ [ -f "${TARGET_DIR:-.}/$f" ] && kt_abs="$kt_abs ${TARGET_DIR:-.}/$f"
7098
+ done
7099
+ if [ -n "$kt_abs" ]; then
7100
+ if command -v ktlint &>/dev/null; then
7101
+ total_checked=$((total_checked + $(echo "$kt_abs" | wc -w)))
7102
+ local kt_out
7103
+ # shellcheck disable=SC2086
7104
+ kt_out=$(cd "${TARGET_DIR:-.}" && ktlint $kt_files 2>&1) || {
7105
+ # Advisory: ktlint reports only style/formatting; warn, do not block.
7106
+ details="${details}ktlint advisory (style, non-blocking): $(echo "$kt_out" | tail -3 | tr '\n' ' '). "
7107
+ log_warn "Static analysis: ktlint reported style findings (advisory, non-blocking)"
7108
+ }
7109
+ elif command -v detekt &>/dev/null; then
7110
+ total_checked=$((total_checked + $(echo "$kt_abs" | wc -w)))
7111
+ local dt_out dt_input
7112
+ dt_input=$(echo "$kt_files" | tr ' \n' ',,' | sed 's/,*$//;s/^,*//')
7113
+ dt_out=$(cd "${TARGET_DIR:-.}" && detekt --input "$dt_input" 2>&1) || {
7114
+ # Advisory: detekt threshold is config-driven, findings are code
7115
+ # smells (no error-severity-only CLI mode); warn, do not block.
7116
+ details="${details}detekt advisory (code smell, non-blocking): $(echo "$dt_out" | tail -3 | tr '\n' ' '). "
7117
+ log_warn "Static analysis: detekt reported findings (advisory, non-blocking)"
7118
+ }
7119
+ else
7120
+ log_info "Static analysis: ktlint/detekt not on PATH, skipping Kotlin check (pass-through)"
7121
+ fi
7122
+ fi
7123
+ fi
7124
+
7125
+ # Java (P1-6: checkstyle is a pure static linter that needs no compile or
7126
+ # classpath, but it REQUIRES a config file. A per-file `javac` would
7127
+ # false-block on unresolved imports/classpath the way per-file tsc did, so
7128
+ # Java is gated on checkstyle-with-config only. Without a config we pass
7129
+ # through honestly. C# is deferred: roslyn analyzers and `dotnet build` need
7130
+ # a full project + restore, which cannot be auto-detected cleanly per-file.)
7131
+ local java_files
7132
+ java_files=$(echo "$changed_files" | grep -E '\.java$' || true)
7133
+ if [ -n "$java_files" ]; then
7134
+ local java_abs=""
7135
+ for f in $java_files; do
7136
+ [ -f "${TARGET_DIR:-.}/$f" ] && java_abs="$java_abs ${TARGET_DIR:-.}/$f"
7137
+ done
7138
+ if [ -n "$java_abs" ]; then
7139
+ local _cs_config=""
7140
+ for cfg in checkstyle.xml .checkstyle.xml config/checkstyle/checkstyle.xml google_checks.xml sun_checks.xml; do
7141
+ if [ -f "${TARGET_DIR:-.}/$cfg" ]; then _cs_config="${TARGET_DIR:-.}/$cfg"; break; fi
7142
+ done
7143
+ if command -v checkstyle &>/dev/null && [ -n "$_cs_config" ]; then
7144
+ total_checked=$((total_checked + $(echo "$java_abs" | wc -w)))
7145
+ local cs_out
7146
+ # checkstyle's exit code equals the count of audit events at
7147
+ # severity=error; warning/info violations are printed but do NOT
7148
+ # bump the exit code (verified against checkstyle CLI behavior).
7149
+ # So a nonzero exit means error-severity findings only -- this is
7150
+ # already error-gated like cppcheck (--error-exitcode) and the
7151
+ # JS/TS/Py `-S error` gates, and does NOT block on style/warning.
7152
+ # Whether a given rule is error vs warning is the user's explicit
7153
+ # choice in their checkstyle config, which we respect.
7154
+ # shellcheck disable=SC2086
7155
+ cs_out=$(cd "${TARGET_DIR:-.}" && checkstyle -c "$_cs_config" $java_files 2>&1) || {
7156
+ findings=$((findings + 1))
7157
+ details="${details}checkstyle (error severity): $(echo "$cs_out" | tail -3 | tr '\n' ' '). "
7158
+ }
7159
+ else
7160
+ log_info "Static analysis: checkstyle+config not available, skipping Java check (pass-through)"
7161
+ fi
7162
+ fi
7163
+ fi
7164
+
7041
7165
  # Write results
7042
7166
  cat > "$quality_dir/static-analysis.json" << SAFEOF
7043
7167
  {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","files_checked":$total_checked,"findings":$findings,"summary":"$details","pass":$([ $findings -eq 0 ] && echo "true" || echo "false")}
@@ -29,6 +29,28 @@
29
29
  # prompt-injected, and surfaced in proof-of-done. LOKI_ASSUMPTIONS_REQUIRE_CONFIRM=1
30
30
  # disables auto-ack for a human-in-the-loop path (only confirmed=true clears).
31
31
  #
32
+ # Design note (P2-4 contradictions -- the exception to the lifecycle above):
33
+ # A GAP can become a recorded assumption: "the spec is silent, so I took the
34
+ # implementer default." A CONTRADICTION cannot -- there is no default that
35
+ # satisfies "X and not-X". So a contradiction (class=contradictory, forced to
36
+ # severity=high) is NEVER auto-acknowledged, even in default autonomous mode
37
+ # (spec_ledger_acknowledge_all skips it). It stays acknowledged=false and keeps
38
+ # the completion gate BLOCKED until a human sets confirmed=true. We chose this
39
+ # "block, do not assume away" behavior over the softer "auto-ack but surface
40
+ # loudly" option because a silently-acked contradiction lets "done" be declared
41
+ # over a spec we know is internally inconsistent, which is exactly the
42
+ # accuracy failure this feature exists to prevent. Honest cost: in a fully
43
+ # autonomous run an unresolved contradiction grinds the loop to max-iterations
44
+ # (no human to confirm), wasting budget. It is STILL surfaced in proof-of-done
45
+ # on every terminal path. Escape hatches: a human sets confirmed=true in
46
+ # .loki/assumptions/, or the operator sets LOKI_ASSUMPTION_GATE=0. A follow-up
47
+ # in run.sh (out of this module's scope) should add an early hard-stop on an
48
+ # unresolved contradiction so the run fails fast instead of grinding.
49
+ # Contradictions come from two sources: spec-INTERNAL (grill finds them, the
50
+ # classifier tags them) and spec-EXTERNAL (spec_interrogation_external_check
51
+ # compares the spec to the repo's declared dependencies; narrow + high-
52
+ # confidence by design).
53
+ #
32
54
  # Provider-aware + clean degrade: grill needs a provider CLI; when absent we log
33
55
  # an honest message, skip the grill subcall (NO fabricated questions), but STILL
34
56
  # fold prd-analyzer's deterministic missing-dimension assumptions into the ledger
@@ -38,6 +60,7 @@
38
60
  # LOKI_SPEC_GRILL=0 skip interrogation entirely
39
61
  # LOKI_ASSUMPTION_GATE=0 completion gate is pass-through (gate file)
40
62
  # LOKI_ASSUMPTIONS_REQUIRE_CONFIRM=1 require human confirmed=true (no auto-ack)
63
+ # LOKI_SPEC_EXTERNAL_CHECK=0 skip the spec-vs-repo external contradiction check
41
64
 
42
65
  set -uo pipefail
43
66
 
@@ -81,6 +104,17 @@ spec_interrogation_severity_for() {
81
104
  printf 'high'; return 0 ;;
82
105
  esac
83
106
 
107
+ # P2-4: a "Contradictions" SECTION escalates to high even when the line has
108
+ # no contradiction keyword. Keyword-only detection would silently miss a
109
+ # contradiction phrased plainly ("section 2 mandates immutable records;
110
+ # section 5 specifies an edit endpoint."), which is exactly the failure P2-4
111
+ # exists to prevent -- and the grill-prompt follow-up that adds a
112
+ # "Contradictions" section would emit such keyword-free findings.
113
+ case "$lc_section" in
114
+ *contradiction*|*contradictor*)
115
+ printf 'high'; return 0 ;;
116
+ esac
117
+
84
118
  # Section-driven severity.
85
119
  case "$lc_section" in
86
120
  *security*|*scale*|*reliability*)
@@ -113,6 +147,13 @@ spec_interrogation_class_for() {
113
147
  printf 'contradictory'; return 0 ;;
114
148
  esac
115
149
 
150
+ # P2-4: a "Contradictions" SECTION tags its findings contradictory even when
151
+ # the line carries no contradiction keyword (see severity_for for rationale).
152
+ case "$lc_section" in
153
+ *contradiction*|*contradictor*)
154
+ printf 'contradictory'; return 0 ;;
155
+ esac
156
+
116
157
  case "$lc_section" in
117
158
  *security*|*scale*|*reliability*) printf 'missing'; return 0 ;;
118
159
  *unstated\ assumption*) printf 'underspecified'; return 0 ;;
@@ -249,9 +290,19 @@ spec_interrogation_classify_report() {
249
290
  # Trim leading/trailing whitespace.
250
291
  stripped="$(printf '%s' "$q" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
251
292
  [ -z "$stripped" ] && continue
252
- # Skip explicit "None identified." placeholders (no fabricated findings).
253
- case "$stripped" in
254
- "None identified"*|"None."*|"None"|"N/A"*) continue ;;
293
+ # Skip negative-result / clean-spec lines so a grill that honestly
294
+ # reports "nothing found" never becomes a persisted finding (and, under
295
+ # "### Contradictions", never deadlocks a clean spec to max-iterations).
296
+ # Match a lowercased copy (bash 3.2 has no ${var,,}); write the original.
297
+ # Patterns are START-anchored to whole-line negative phrasings so a real
298
+ # finding that merely contains "no" (e.g. "no input validation on the
299
+ # login endpoint") is NOT skipped.
300
+ local stripped_lc
301
+ stripped_lc="$(printf '%s' "$stripped" | tr '[:upper:]' '[:lower:]')"
302
+ case "$stripped_lc" in
303
+ "none"|"none."*|"none found"*|"none identified"*|\
304
+ "no contradiction"*|"no issues"*|"no conflicts"*|"no problems"*|\
305
+ "no concerns"*|"no gaps"*|"not applicable"*|"n/a"*) continue ;;
255
306
  esac
256
307
 
257
308
  local sev class affects assumption
@@ -260,7 +311,20 @@ spec_interrogation_classify_report() {
260
311
  affects="$(_spec_affects_for "$section")"
261
312
  # No-fabrication: the finding is a QUESTION; the honest assumption is a
262
313
  # stated default, NOT an invented resolution the build will not follow.
263
- assumption="Spec gives no answer; proceeding with the implementer default for ${affects}."
314
+ # P2-4: a CONTRADICTION is special. A gap can become a recorded
315
+ # assumption (a stated default), but a contradiction CANNOT be assumed
316
+ # away -- there is no consistent default for "X and not-X". So a
317
+ # contradictory finding carries a contradiction-specific message instead
318
+ # of the implementer-default text. This keeps spec_ledger_prompt_block
319
+ # honest (it would otherwise instruct the build agent to "proceed with a
320
+ # default" for something that has no consistent default) and makes the
321
+ # ledger rollup state the truth: unresolvable by assumption, needs a
322
+ # human.
323
+ if [ "$class" = "contradictory" ]; then
324
+ assumption="UNRESOLVED CONTRADICTION: the spec is internally inconsistent here and cannot be assumed away; a human must resolve it before this can be built correctly."
325
+ else
326
+ assumption="Spec gives no answer; proceeding with the implementer default for ${affects}."
327
+ fi
264
328
 
265
329
  spec_ledger_write \
266
330
  "$stripped" \
@@ -274,6 +338,144 @@ spec_interrogation_classify_report() {
274
338
  return 0
275
339
  }
276
340
 
341
+ # ---------------------------------------------------------------------------
342
+ # P2-4 EXTERNAL contradiction check: spec vs existing code/config.
343
+ #
344
+ # A spec-internal contradiction is found by the grill (LLM) and recognized by
345
+ # the classifier above. An EXTERNAL contradiction is the spec disagreeing with
346
+ # the repo it is being built into (spec says Postgres, repo wired to Mongo).
347
+ #
348
+ # Design constraint -- PRECISION OVER RECALL (deliberate):
349
+ # A contradiction is tagged high + contradictory and (per the auto-ack skip
350
+ # above) BLOCKS completion until a human resolves it. In an autonomous run no
351
+ # human is present, so a false positive grinds a GOOD spec to max-iterations.
352
+ # That is a severe failure mode. A grep heuristic is far lower-confidence than
353
+ # the LLM-identified internal contradictions feeding the same blocking path, so
354
+ # this check fires ONLY on UNAMBIGUOUS POSITIVE-CONFLICT evidence and is happy
355
+ # to miss real conflicts (low recall) rather than ever block a clean spec.
356
+ #
357
+ # Scope of THIS slice: database-engine conflict only -- the single signal where
358
+ # "what the spec names" and "what the repo is wired to" are both concretely
359
+ # detectable from declared dependencies. The trigger requires ALL FOUR:
360
+ # 1. the spec explicitly names database engine X, AND
361
+ # 2. a manifest (package.json / requirements.txt / go.mod / pyproject.toml /
362
+ # Gemfile) declares a concrete driver for a DIFFERENT engine Y, AND
363
+ # 3. that same manifest declares NO driver for engine X, AND
364
+ # 4. the spec does NOT also name engine Y (if it names BOTH, the spec is
365
+ # discussing the choice -- "we chose Mongo over Postgres" -- not in
366
+ # conflict; skip rather than false-fire on a good spec).
367
+ # Bare prose substring matches in source files are intentionally NOT used (specs
368
+ # mention databases in passing; comments and migrations carry both drivers).
369
+ #
370
+ # Honest deferral: other external conflicts (REST-vs-gRPC, language/runtime,
371
+ # cloud provider) are NOT implemented here. They need a higher-confidence
372
+ # extractor than a single grep and are documented as the harder follow-up.
373
+ #
374
+ # Usage: spec_interrogation_external_check <spec_path>
375
+ # Best-effort; writes at most one contradiction ledger entry; never fails a run.
376
+ # ---------------------------------------------------------------------------
377
+ spec_interrogation_external_check() {
378
+ local spec_path="${1:-}"
379
+ [ -n "$spec_path" ] && [ -f "$spec_path" ] || return 0
380
+ [ "${LOKI_SPEC_EXTERNAL_CHECK:-1}" = "0" ] && return 0
381
+
382
+ local repo_root="${TARGET_DIR:-.}"
383
+
384
+ # Collect declared-dependency manifest text (declarations only, not prose).
385
+ local manifests="" m
386
+ for m in \
387
+ "$repo_root/package.json" \
388
+ "$repo_root/requirements.txt" \
389
+ "$repo_root/pyproject.toml" \
390
+ "$repo_root/go.mod" \
391
+ "$repo_root/Gemfile"; do
392
+ [ -f "$m" ] && manifests="$manifests $m"
393
+ done
394
+ # No declared dependencies => no concrete repo signal => nothing to conflict.
395
+ [ -n "$manifests" ] || return 0
396
+
397
+ # Lowercase the spec body and the manifest text once.
398
+ local spec_lc deps_lc
399
+ spec_lc="$(tr '[:upper:]' '[:lower:]' < "$spec_path" 2>/dev/null)"
400
+ # shellcheck disable=SC2086 # word-split of the manifest path list is intended
401
+ deps_lc="$(cat $manifests 2>/dev/null | tr '[:upper:]' '[:lower:]')"
402
+ [ -n "$spec_lc" ] && [ -n "$deps_lc" ] || return 0
403
+
404
+ # Engine -> concrete driver-dependency token (a dependency name, not prose).
405
+ # Keys are the engine names we look for in the SPEC; values are the package
406
+ # tokens that prove the repo is wired to that engine.
407
+ _spec_db_driver_token() {
408
+ case "$1" in
409
+ postgres) printf '%s' 'pg|psycopg|postgresql|asyncpg|node-postgres|sequelize-postgres|gorm.io/driver/postgres' ;;
410
+ mongodb) printf '%s' 'mongoose|pymongo|mongodb|motor|go.mongodb.org/mongo-driver' ;;
411
+ mysql) printf '%s' 'mysql|mysql2|pymysql|mysqlclient|gorm.io/driver/mysql' ;;
412
+ *) printf '' ;;
413
+ esac
414
+ }
415
+ # Does the spec name engine $1? Match unambiguous engine names only.
416
+ _spec_names_engine() {
417
+ case "$1" in
418
+ postgres) printf '%s' 'postgres\|postgresql' ;;
419
+ mongodb) printf '%s' 'mongodb\|mongo db\|mongo database' ;;
420
+ mysql) printf '%s' 'mysql' ;;
421
+ *) printf '' ;;
422
+ esac
423
+ }
424
+
425
+ local engines="postgres mongodb mysql"
426
+ local spec_engine other_engine
427
+ for spec_engine in $engines; do
428
+ local spec_pat
429
+ spec_pat="$(_spec_names_engine "$spec_engine")"
430
+ [ -n "$spec_pat" ] || continue
431
+ printf '%s' "$spec_lc" | grep -q -e "$spec_pat" || continue
432
+
433
+ # Spec names this engine. Is the repo wired to a DIFFERENT one, with no
434
+ # driver for the spec's engine?
435
+ local spec_engine_token
436
+ spec_engine_token="$(_spec_db_driver_token "$spec_engine")"
437
+ # If the repo DOES declare a driver for the spec's engine, there is no
438
+ # conflict (they agree) -- skip.
439
+ if printf '%s' "$deps_lc" | grep -E -q -- "$spec_engine_token"; then
440
+ continue
441
+ fi
442
+
443
+ for other_engine in $engines; do
444
+ [ "$other_engine" = "$spec_engine" ] && continue
445
+ # PRECISION guard: if the spec ALSO names other_engine, this is not an
446
+ # unambiguous conflict -- the spec is discussing both engines (e.g.
447
+ # "we chose MongoDB over PostgreSQL"). Skip rather than false-fire on
448
+ # a good spec, since a false contradiction would block it to max-iter.
449
+ local other_spec_pat
450
+ other_spec_pat="$(_spec_names_engine "$other_engine")"
451
+ if [ -n "$other_spec_pat" ] && printf '%s' "$spec_lc" | grep -q -e "$other_spec_pat"; then
452
+ continue
453
+ fi
454
+ local other_token
455
+ other_token="$(_spec_db_driver_token "$other_engine")"
456
+ [ -n "$other_token" ] || continue
457
+ if printf '%s' "$deps_lc" | grep -E -q -- "$other_token"; then
458
+ # UNAMBIGUOUS: spec names X, repo declares a Y driver, repo has
459
+ # no X driver. Record one high/contradictory external finding.
460
+ local gap assumption
461
+ gap="External contradiction: the spec specifies the ${spec_engine} database, but this repository declares a ${other_engine} driver dependency and no ${spec_engine} driver."
462
+ assumption="UNRESOLVED CONTRADICTION: the spec and the existing code disagree on the database engine and this cannot be assumed away; a human must reconcile the spec with the repo before building."
463
+ spec_ledger_write \
464
+ "$gap" \
465
+ "$assumption" \
466
+ "external: spec vs repo dependencies" \
467
+ "high" \
468
+ "contradictory" \
469
+ "data-store" \
470
+ "external-check"
471
+ # One database-engine conflict is enough to block; stop scanning.
472
+ return 0
473
+ fi
474
+ done
475
+ done
476
+ return 0
477
+ }
478
+
277
479
  # ---------------------------------------------------------------------------
278
480
  # Fold prd-analyzer's deterministic missing-dimension assumptions into the
279
481
  # ledger as medium (non-blocking). Reads .loki/prd-observations.md "Assumptions
@@ -371,6 +573,16 @@ print("%d %d" % (total, high))
371
573
  # Auto-acknowledgment lifecycle helper: set acknowledged=true on every ledger
372
574
  # entry. run.sh calls this once an iteration AFTER assumptions are injected into
373
575
  # the build prompt (unless LOKI_ASSUMPTIONS_REQUIRE_CONFIRM=1). Best-effort.
576
+ #
577
+ # P2-4 EXCEPTION: class=contradictory entries are NEVER auto-acknowledged. A gap
578
+ # can be assumed away (auto-ack records that the implementer-default was taken),
579
+ # but a contradiction is unresolvable by assumption -- there is no default that
580
+ # satisfies "X and not-X". Auto-acknowledging it would silently clear the
581
+ # completion gate and let "done" be declared over an internally inconsistent
582
+ # spec. So a contradiction stays acknowledged=false until a human confirms a
583
+ # resolution (sets confirmed=true). This is deliberately the same teeth the
584
+ # LOKI_ASSUMPTIONS_REQUIRE_CONFIRM=1 path applies to ALL entries, scoped here to
585
+ # just contradictions in default autonomous mode. See the design note below.
374
586
  # ---------------------------------------------------------------------------
375
587
  spec_ledger_acknowledge_all() {
376
588
  [ "${LOKI_ASSUMPTIONS_REQUIRE_CONFIRM:-0}" = "1" ] && return 0
@@ -388,6 +600,9 @@ for p in glob.glob(os.path.join(d, "a-*.json")):
388
600
  continue
389
601
  if r.get("acknowledged"):
390
602
  continue
603
+ # P2-4: a contradiction cannot be assumed away, so it is never auto-acked.
604
+ if r.get("class") == "contradictory":
605
+ continue
391
606
  r["acknowledged"] = True
392
607
  fd, tmp = tempfile.mkstemp(dir=os.path.dirname(p), suffix=".tmp")
393
608
  try:
@@ -531,6 +746,11 @@ spec_interrogation_run() {
531
746
  # (works with no provider) so degrade still surfaces something.
532
747
  spec_ledger_fold_prd_observations || true
533
748
 
749
+ # P2-4: best-effort EXTERNAL contradiction check (spec vs repo deps). Runs
750
+ # with no provider (it is pure file inspection) and is intentionally narrow
751
+ # (high-confidence DB-engine conflict only) so it never blocks a clean spec.
752
+ spec_interrogation_external_check "$spec_path" || true
753
+
534
754
  spec_ledger_rebuild_md || true
535
755
 
536
756
  local counts total high
package/autonomy/spec.sh CHANGED
@@ -470,12 +470,17 @@ PYEOF
470
470
  # drift check quietly and, on drift, emits ONE SPEC_DRIFT record to stdout in
471
471
  # the verify finding TSV shape:
472
472
  # severity \t category \t source \t file \t line \t message
473
- # Severity is Medium (-> CONCERNS, per the task). Graceful no-op (prints
474
- # nothing, returns 0) when there is no lock or the spec cannot be resolved.
473
+ # Severity is High (-> BLOCKED under verify's default --block-on critical,high).
474
+ # A spec lock is an explicit human declaration that "this spec is the contract"
475
+ # (loki spec lock / sync), so once it exists, real drift is a blocking gate, not
476
+ # a soft concern. Graceful no-op (prints nothing, returns 0) when there is no
477
+ # lock or the spec cannot be resolved, so an unlocked / first-run workflow is
478
+ # never blocked.
475
479
  #
476
480
  # This function is intentionally side-effect-light for the verify caller: it
477
- # still writes the drift-report.json (a useful artifact) but never blocks and
478
- # never prints to the verify human channel.
481
+ # still writes the drift-report.json (a useful artifact) and never prints to the
482
+ # verify human channel; the BLOCK is delivered purely via the High finding the
483
+ # verify verdict logic consumes.
479
484
  # ---------------------------------------------------------------------------
480
485
  spec_verify_hook() {
481
486
  local out_dir="${1:-$SPEC_DIR_DEFAULT}"
@@ -485,16 +490,17 @@ spec_verify_hook() {
485
490
  local spec_path
486
491
  # Prefer the spec path recorded in the lock; fall back to resolution.
487
492
  spec_path="$(python3 -c 'import sys,json; print(json.load(open(sys.argv[1])).get("spec_path",""))' "$lock_file" 2>/dev/null || echo "")"
488
- # MEDIUM-4: the lock recorded a spec path but that file is now MISSING (the
489
- # locked spec was deleted). That is real drift -- the contract the lock binds
490
- # no longer exists -- so emit a Medium spec_drift finding instead of silently
491
- # returning 0. NEVER fall back to spec_resolve_source here: comparing against
492
- # a different candidate file would mask the deletion and attest a spec that is
493
- # not the locked one. The empty-spec_path case below is a SEPARATE, legitimate
494
- # fallback (legacy locks that never recorded a path).
493
+ # The lock recorded a spec path but that file is now MISSING (the locked spec
494
+ # was deleted). That is real drift -- the contract the lock binds no longer
495
+ # exists -- so emit a High spec_drift finding (blocking, consistent with the
496
+ # content-drift finding below) instead of silently returning 0. NEVER fall
497
+ # back to spec_resolve_source here: comparing against a different candidate
498
+ # file would mask the deletion and attest a spec that is not the locked one.
499
+ # The empty-spec_path case below is a SEPARATE, legitimate fallback (legacy
500
+ # locks that never recorded a path).
495
501
  if [ -n "$spec_path" ] && [ ! -f "$spec_path" ]; then
496
502
  printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
497
- "Medium" "spec_drift" "deterministic:loki-spec" "$spec_path" "null" \
503
+ "High" "spec_drift" "deterministic:loki-spec" "$spec_path" "null" \
498
504
  "locked spec file missing: $spec_path (the spec is the contract; restore it or run 'loki spec sync' after review to re-lock against the current spec)"
499
505
  return 0
500
506
  fi
@@ -523,9 +529,10 @@ PYEOF
523
529
  )"
524
530
  [ -n "$summary" ] || return 0
525
531
 
526
- # Emit one Medium SPEC_DRIFT finding in the verify TSV shape.
532
+ # Emit one High SPEC_DRIFT finding in the verify TSV shape (blocking under
533
+ # verify's default --block-on critical,high; only reachable when a lock exists).
527
534
  printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
528
- "Medium" "spec_drift" "deterministic:loki-spec" "$spec_path" "null" "$summary"
535
+ "High" "spec_drift" "deterministic:loki-spec" "$spec_path" "null" "$summary"
529
536
  return 0
530
537
  }
531
538
 
@@ -572,8 +579,10 @@ EXIT CODES:
572
579
 
573
580
  VERIFY INTEGRATION:
574
581
  When .loki/spec/spec.lock exists, `loki verify` runs the drift check and
575
- adds a Medium-severity SPEC_DRIFT finding on drift, which maps to a CONCERNS
576
- verdict. No lock = graceful no-op.
582
+ adds a High-severity SPEC_DRIFT finding on drift, which BLOCKS verify under
583
+ its default --block-on critical,high. Locking is an explicit declaration
584
+ that the spec is the contract, so post-lock drift is a hard gate. No lock =
585
+ graceful no-op (gate skipped, never blocks an unlocked / first-run workflow).
577
586
 
578
587
  EOF
579
588
  }