loki-mode 7.93.0 → 7.94.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.
@@ -0,0 +1,823 @@
1
+ #!/usr/bin/env bash
2
+ # done-recognition.sh -- model-verified "already done?" gate for no-PRD reuse runs.
3
+ #
4
+ # Problem: a `loki start` with NO PRD over a project Loki already built and
5
+ # completed reverse-engineers (reuses) the prior generated PRD and then rebuilds
6
+ # a full task queue and re-runs the RARV loop, re-doing finished work. The reuse
7
+ # path never asks "is this reused PRD already satisfied by the current codebase?"
8
+ #
9
+ # This module inserts ONE localized gate in run_autonomous() between load_state
10
+ # and the queue/loop. On a no-PRD reuse run it re-verifies ground truth with the
11
+ # model (re-runs tests via the existing completion-test-evidence path, inspects
12
+ # code per requirement) and routes to one of three outcomes:
13
+ # - done -> refresh the verified-completion record + finish through the
14
+ # normal completion path (no wasted iterations, no queue).
15
+ # - incomplete -> write a satisfied-requirements manifest so populate_prd_queue
16
+ # builds ONLY the unsatisfied requirements.
17
+ # - inconclusive-> do nothing; fall through to the normal full build (safe
18
+ # default). NEVER declare done on inconclusive.
19
+ #
20
+ # TRUST MOAT: a model `done` is DOWNGRADED to build if fresh tests are red or any
21
+ # requirement is unmet/uncertain. The positive verdict is always the model's,
22
+ # grounded in re-run reality, never asserted from a stale artifact. The `update`
23
+ # action (PRD stale by definition) may NEVER fast-stop as done.
24
+ #
25
+ # MODEL INTELLIGENCE, NEVER HARDCODED: the only deterministic short-circuit is
26
+ # NEGATIVE (cheap signals that route to BUILD). There is no deterministic
27
+ # "checklist all-verified -> stop" shortcut.
28
+ #
29
+ # Rollout: DEFAULT-ON. LOKI_DONE_RECOGNITION=0 disables the gate (legacy
30
+ # reuse-then-build behavior). Trust-safe because inconclusive always falls
31
+ # through to build.
32
+ #
33
+ # Indirection (for testability): the actual model call goes through
34
+ # _loki_done_recog_invoke <prompt> (echoes the raw model response)
35
+ # Tests stub this function to return canned JSON without a real model. This is
36
+ # the single injection seam, mirroring autonomy/lib/prd-enrich.sh.
37
+ #
38
+ # No emojis. No em dashes. bash 3.2 safe. Honors `set -uo pipefail`.
39
+
40
+ # Bound the single model call so a huge PRD or test log cannot run away.
41
+ : "${LOKI_DONE_RECOG_TIMEOUT:=180}" # seconds for the single model call
42
+ : "${LOKI_DONE_RECOG_MAX_PRD_CHARS:=16000}" # cap PRD context length
43
+ : "${LOKI_DONE_RECOG_MAX_TEST_CHARS:=4000}" # cap test-results context length
44
+
45
+ # The single model-call primitive. Kept as its own function so:
46
+ # 1. it is the ONE place that touches the provider, and
47
+ # 2. tests can override it to return canned JSON.
48
+ # Mirrors _loki_prd_enrich_invoke (autonomy/lib/prd-enrich.sh:43) verbatim in
49
+ # shape. Calls `claude -p` directly (not provider_invoke) because `timeout`
50
+ # needs a real command, not a shell function.
51
+ _loki_done_recog_invoke() {
52
+ local prompt="$1"
53
+ command -v claude >/dev/null 2>&1 || return 1
54
+ local rc=0
55
+ local out=""
56
+ if command -v timeout >/dev/null 2>&1; then
57
+ out=$(CAVEMAN_DEFAULT_MODE=off timeout "${LOKI_DONE_RECOG_TIMEOUT}" \
58
+ claude --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
59
+ else
60
+ out=$(CAVEMAN_DEFAULT_MODE=off \
61
+ claude --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
62
+ fi
63
+ [ "$rc" -ne 0 ] && return 1
64
+ [ -z "$out" ] && return 1
65
+ printf '%s' "$out"
66
+ return 0
67
+ }
68
+
69
+ # Decide whether model verification can be attempted. Returns 0 (ok) only when
70
+ # the active provider is claude and not degraded. Mirrors
71
+ # _loki_prd_enrich_provider_ok (autonomy/lib/prd-enrich.sh:65).
72
+ _loki_done_recog_provider_ok() {
73
+ [ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
74
+ [ "${PROVIDER_DEGRADED:-false}" != "true" ] || return 1
75
+ command -v claude >/dev/null 2>&1 || return 1
76
+ return 0
77
+ }
78
+
79
+ # Compute the PRD identity hash. MUST be byte-identical here (writer) and in
80
+ # populate_prd_queue's manifest read-point (reader) or the guard always
81
+ # mismatches and silently falls back to a full build. Pinned to the same file
82
+ # and the same hashing path on both sides via this one helper.
83
+ _loki_done_recog_prd_sha() {
84
+ local prd_file="${1:-}"
85
+ [ -n "$prd_file" ] && [ -f "$prd_file" ] || { printf ''; return 0; }
86
+ if command -v shasum >/dev/null 2>&1; then
87
+ shasum -a 256 "$prd_file" 2>/dev/null | awk '{print $1}'
88
+ elif command -v sha256sum >/dev/null 2>&1; then
89
+ sha256sum "$prd_file" 2>/dev/null | awk '{print $1}'
90
+ else
91
+ # Last-resort: a python hash, still deterministic over the same bytes.
92
+ LOKI_DR_PRD="$prd_file" python3 -c "
93
+ import hashlib, os
94
+ p = os.environ.get('LOKI_DR_PRD','')
95
+ try:
96
+ with open(p,'rb') as f:
97
+ print(hashlib.sha256(f.read()).hexdigest())
98
+ except Exception:
99
+ print('')
100
+ " 2>/dev/null
101
+ fi
102
+ }
103
+
104
+ # The gate. Called once, run-scoped, from run_autonomous() AFTER load_state and
105
+ # BEFORE the delegate-branch/start-sha block and populate_prd_queue.
106
+ #
107
+ # Arguments:
108
+ # $1 = prd_path (the reused generated PRD, e.g. .loki/generated-prd.md)
109
+ #
110
+ # Behavior by outcome:
111
+ # done -> writes refreshed completion record, runs council-parity
112
+ # finalization subset (all type-guarded), returns 0 so the
113
+ # caller's `return 0` skips queue-build + loop and main()'s
114
+ # terminal block finalizes the run.
115
+ # incomplete -> writes .loki/state/satisfied-requirements.json, returns 1 so
116
+ # the caller falls through to a (now incremental) build.
117
+ # inconclusive/fast-path/disabled -> returns 1 (fall through to build).
118
+ #
119
+ # Return contract:
120
+ # 0 = DONE (caller must short-circuit: `return 0` from run_autonomous)
121
+ # 1 = BUILD (caller falls through to the normal queue/loop)
122
+ reuse_done_recognition_gate() {
123
+ local prd_path="${1:-}"
124
+ local action="${GENERATED_PRD_ACTION:-}"
125
+ local loki_dir="${TARGET_DIR:-.}/.loki"
126
+
127
+ # --- Opt-out (rollout escape hatch). Default-on. -------------------------
128
+ if [ "${LOKI_DONE_RECOGNITION:-1}" = "0" ]; then
129
+ return 1
130
+ fi
131
+
132
+ # Must have a usable reused PRD to judge against.
133
+ [ -n "$prd_path" ] && [ -f "$prd_path" ] || return 1
134
+
135
+ # --- Negative fast-path A: no completion footprint at all ----------------
136
+ # The project was never completed by a prior run; there is nothing plausibly
137
+ # done. Never pay for a model call. (Cheapest, most common miss-avoidance.)
138
+ if [ ! -f "$loki_dir/signals/COMPLETION_REQUESTED" ] \
139
+ && [ ! -f "$loki_dir/state/completion.json" ] \
140
+ && [ ! -f "$loki_dir/checklist/checklist.json" ]; then
141
+ log_info "Done-recognition: no prior completion footprint; proceeding to build."
142
+ return 1
143
+ fi
144
+
145
+ # --- Negative fast-path B: provider cannot model-verify -------------------
146
+ # Cannot model-verify -> inconclusive -> build (never assert done offline).
147
+ if ! _loki_done_recog_provider_ok; then
148
+ log_info "Done-recognition: provider cannot verify (non-claude/degraded/no binary); proceeding to build."
149
+ return 1
150
+ fi
151
+
152
+ log_step "Done-recognition: checking whether the existing code already satisfies the reused spec..."
153
+
154
+ # --- Ground-truth re-verification: re-run tests NOW ----------------------
155
+ # Reuse the SAME evidence axis the completion council/evidence gate reads, so
156
+ # the gate cannot reach a verdict that contradicts the council. Swallow rc
157
+ # (red tests are data, not a crash). When unavailable the file is simply
158
+ # absent and the test axis is honestly inconclusive.
159
+ if type ensure_completion_test_evidence >/dev/null 2>&1; then
160
+ ensure_completion_test_evidence || true
161
+ fi
162
+ local _test_results="$loki_dir/quality/test-results.json"
163
+
164
+ # --- Build the model prompt payload (python: bounded, defensive) ---------
165
+ local prompt
166
+ prompt=$(LOKI_DR_PRD="$prd_path" \
167
+ LOKI_DR_TESTS="$_test_results" \
168
+ LOKI_DR_COMPLETION="$loki_dir/state/completion.json" \
169
+ LOKI_DR_EVIDENCE="$loki_dir/completion-evidence.md" \
170
+ LOKI_DR_CHECKLIST="$loki_dir/checklist/checklist.json" \
171
+ LOKI_DR_MAX_PRD="${LOKI_DONE_RECOG_MAX_PRD_CHARS}" \
172
+ LOKI_DR_MAX_TEST="${LOKI_DONE_RECOG_MAX_TEST_CHARS}" \
173
+ python3 << 'DR_PROMPT_EOF'
174
+ import json, os, sys
175
+
176
+ def read_capped(path, cap):
177
+ try:
178
+ with open(path, "r", errors="replace") as f:
179
+ return f.read()[:cap]
180
+ except Exception:
181
+ return ""
182
+
183
+ prd = read_capped(os.environ.get("LOKI_DR_PRD", ""),
184
+ int(os.environ.get("LOKI_DR_MAX_PRD", "16000") or "16000"))
185
+ if not prd.strip():
186
+ sys.exit(0)
187
+
188
+ tests = read_capped(os.environ.get("LOKI_DR_TESTS", ""),
189
+ int(os.environ.get("LOKI_DR_MAX_TEST", "4000") or "4000"))
190
+ completion = read_capped(os.environ.get("LOKI_DR_COMPLETION", ""), 2000)
191
+ evidence = read_capped(os.environ.get("LOKI_DR_EVIDENCE", ""), 2000)
192
+ checklist = read_capped(os.environ.get("LOKI_DR_CHECKLIST", ""), 2000)
193
+
194
+ prompt = """You are deciding whether a codebase ALREADY satisfies its spec, so a
195
+ build system can skip rebuilding work that is already done. Be rigorous and
196
+ conservative: a wrong "done" wastes the user's trust, a wrong "incomplete" only
197
+ costs a little rebuild. When unsure, say uncertain.
198
+
199
+ For EACH requirement in the PRD below:
200
+ - Inspect the ACTUAL code in this repository (read the files) and the fresh
201
+ test results, and decide whether the requirement is met NOW.
202
+ - Treat all prior Loki artifacts (PRIOR CLAIMS section) as UNVERIFIED claims.
203
+ Do NOT trust them; verify against the code and the fresh test results.
204
+
205
+ Then return ONLY a single JSON object (no prose, no markdown fences):
206
+ {
207
+ "verdict": "done" | "incomplete" | "inconclusive",
208
+ "summary": "<one plain sentence for the user>",
209
+ "tests": { "passed": <int>, "total": <int>, "green": true|false },
210
+ "requirements": [
211
+ { "id": "<stable id or title slug>",
212
+ "title": "<requirement title, matching the PRD feature heading>",
213
+ "status": "met" | "unmet" | "uncertain",
214
+ "evidence": "<file:line or test name proving it>" }
215
+ ]
216
+ }
217
+
218
+ Verdict rules:
219
+ - "done" ONLY when ALL requirements are "met" AND the fresh tests are green
220
+ (or there is no test runner and you can cite concrete code evidence for
221
+ every requirement).
222
+ - "incomplete" when one or more requirements are "unmet".
223
+ - "inconclusive" when you cannot establish ground truth.
224
+ No emojis. No em dashes.
225
+
226
+ === PRD (the requirements to verify) ===
227
+ %s
228
+
229
+ === FRESH TEST RESULTS (re-run now; authoritative for the test axis) ===
230
+ %s
231
+
232
+ === PRIOR CLAIMS (possibly stale; verify, do not trust) ===
233
+ completion.json:
234
+ %s
235
+ completion-evidence.md:
236
+ %s
237
+ checklist.json:
238
+ %s
239
+ """ % (prd, tests or "(no test results captured)",
240
+ completion or "(none)", evidence or "(none)", checklist or "(none)")
241
+
242
+ sys.stdout.write(prompt)
243
+ DR_PROMPT_EOF
244
+ )
245
+
246
+ # Empty payload (unreadable/empty PRD) -> inconclusive -> build.
247
+ if [ -z "$prompt" ]; then
248
+ log_info "Done-recognition: could not build a verification payload; proceeding to build."
249
+ return 1
250
+ fi
251
+
252
+ # --- The single model call (the mockable seam) ---------------------------
253
+ local response
254
+ response=$(_loki_done_recog_invoke "$prompt") || {
255
+ log_info "Done-recognition: model verification unavailable (timeout/error); proceeding to build."
256
+ return 1
257
+ }
258
+ if [ -z "$response" ]; then
259
+ log_info "Done-recognition: empty verification response; proceeding to build."
260
+ return 1
261
+ fi
262
+
263
+ # --- Parse + DEFENSIVELY re-derive the verdict (never trust top-line) ----
264
+ # The python parser slices first '{' to last '}' (tolerate prose/fences),
265
+ # re-derives the verdict from the per-requirement statuses + fresh test
266
+ # axis, and emits a compact result the bash side routes on. `update` action
267
+ # may NEVER yield a fast-stop done: it is forced to "incomplete" when the
268
+ # model said done (downgraded to inconclusive if no requirement is met).
269
+ local parsed
270
+ parsed=$(LOKI_DR_RESP="$response" \
271
+ LOKI_DR_TESTS="$_test_results" \
272
+ LOKI_DR_ACTION="$action" \
273
+ LOKI_DR_PRD="$prd_path" \
274
+ python3 << 'DR_PARSE_EOF'
275
+ import json, os, re, sys
276
+
277
+ resp = os.environ.get("LOKI_DR_RESP", "")
278
+ action = os.environ.get("LOKI_DR_ACTION", "")
279
+ tests_path = os.environ.get("LOKI_DR_TESTS", "")
280
+ prd_path = os.environ.get("LOKI_DR_PRD", "")
281
+
282
+ def parse_object(text):
283
+ try:
284
+ v = json.loads(text)
285
+ if isinstance(v, dict):
286
+ return v
287
+ except Exception:
288
+ pass
289
+ start = text.find("{")
290
+ end = text.rfind("}")
291
+ if start == -1 or end == -1 or end <= start:
292
+ return None
293
+ try:
294
+ v = json.loads(text[start:end + 1])
295
+ return v if isinstance(v, dict) else None
296
+ except Exception:
297
+ return None
298
+
299
+ obj = parse_object(resp)
300
+ if obj is None:
301
+ print(json.dumps({"verdict": "inconclusive", "reason": "unparsable verdict",
302
+ "summary": "", "satisfied": []}))
303
+ sys.exit(0)
304
+
305
+ reqs = obj.get("requirements")
306
+ if not isinstance(reqs, list):
307
+ reqs = []
308
+
309
+ statuses = []
310
+ satisfied = []
311
+ for r in reqs:
312
+ if not isinstance(r, dict):
313
+ continue
314
+ st = str(r.get("status", "")).strip().lower()
315
+ title = (r.get("title") or "").strip()
316
+ statuses.append(st)
317
+ if st == "met" and title:
318
+ satisfied.append(title)
319
+
320
+ # Fresh-test axis: authoritative. Read the persisted test-results.json and
321
+ # decide green/red/unknown INDEPENDENTLY of the model's self-report.
322
+ def tests_axis(path):
323
+ try:
324
+ with open(path, "r") as f:
325
+ d = json.load(f)
326
+ except Exception:
327
+ return "unknown" # no runner / no file -> not authoritative
328
+ if not isinstance(d, dict):
329
+ return "unknown"
330
+ # PRODUCTION shapes first (enforce_test_coverage in run.sh writes these):
331
+ # real runner green : {"pass": true, "status": "verified", "exit_code": 0}
332
+ # real runner red : {"pass": false, "status": "failed", "exit_code": 1}
333
+ # no runner : {"pass": "inconclusive", "status": "not_run", "runner": "none"}
334
+ status = str(d.get("status", "")).strip().lower()
335
+ if status in ("not_run", "skipped", "none", "inconclusive"):
336
+ return "unknown" # no authoritative run happened
337
+ # exit_code is the most authoritative red signal when present and numeric.
338
+ ec = d.get("exit_code")
339
+ if isinstance(ec, bool):
340
+ ec = None # guard: bool is an int subclass; do not treat true/false as 0/1
341
+ if isinstance(ec, int):
342
+ if ec != 0:
343
+ return "red"
344
+ # exit 0 alone is green only if nothing else contradicts it (checked below).
345
+ p = d.get("pass")
346
+ if p is True:
347
+ # A clean pass:true with no contradicting red signal is authoritative green.
348
+ if d.get("failed") in (None, 0) and (ec in (None, 0)):
349
+ return "green"
350
+ if p is False:
351
+ return "red"
352
+ # Legacy / generic shapes.
353
+ failed = d.get("failed")
354
+ if isinstance(failed, int):
355
+ return "green" if failed == 0 else "red"
356
+ if status in ("pass", "passed", "green", "ok", "success", "verified"):
357
+ return "green"
358
+ if status in ("fail", "failed", "red", "error"):
359
+ return "red"
360
+ passed = d.get("passed")
361
+ total = d.get("total")
362
+ if isinstance(passed, int) and isinstance(total, int) and total > 0:
363
+ return "green" if passed >= total else "red"
364
+ # exit_code 0 with no other signal: treat as green (a runner ran and succeeded).
365
+ if isinstance(ec, int) and ec == 0:
366
+ return "green"
367
+ return "unknown"
368
+
369
+ axis = tests_axis(tests_path)
370
+
371
+ # Requirement COVERAGE guard (deterministic, NEGATIVE-only: it can only route
372
+ # toward build, never toward done). The model returning "all met" over a SUBSET
373
+ # of the PRD is a fake-green: it silently declares unbuilt features satisfied.
374
+ # So we independently parse the PRD's feature/requirement titles and require the
375
+ # model to have COVERED every one. Any PRD title the model did not return a
376
+ # status for -> coverage gap -> done is not trustworthy.
377
+ def _norm_title(t):
378
+ t = (t or "").strip().lower()
379
+ # Mirror the populate_prd_queue normalization: drop a leading
380
+ # "feature:"/"requirement:" label and surrounding markdown/heading marks.
381
+ t = re.sub(r"^\s*(?:feature|requirement)\s*[:\-]\s*", "", t)
382
+ t = re.sub(r"^[#\s>*\-]+", "", t)
383
+ t = re.sub(r"\s+", " ", t).strip()
384
+ return t
385
+
386
+ def prd_feature_titles(path):
387
+ """Best-effort PRD feature/requirement titles. Honest: returns None when we
388
+ cannot reliably enumerate (so the guard does NOT fire on an unparseable PRD
389
+ and wrongly block a real done -- it only fires when we DO know the set)."""
390
+ if not path or not os.path.isfile(path):
391
+ return None
392
+ try:
393
+ with open(path, "r", errors="replace") as f:
394
+ raw = f.read()
395
+ except Exception:
396
+ return None
397
+ if not raw.strip():
398
+ return None
399
+ titles = []
400
+ # JSON PRD: collect "title"/"name"/"feature"/"requirement" values.
401
+ try:
402
+ d = json.loads(raw)
403
+ def walk(o):
404
+ if isinstance(o, dict):
405
+ for k, v in o.items():
406
+ if k in ("title", "name", "feature", "requirement") and isinstance(v, str) and v.strip():
407
+ titles.append(v)
408
+ else:
409
+ walk(v)
410
+ elif isinstance(o, list):
411
+ for it in o:
412
+ walk(it)
413
+ walk(d)
414
+ if titles:
415
+ return [_norm_title(t) for t in titles if _norm_title(t)]
416
+ except Exception:
417
+ pass
418
+ # Markdown PRD. Loki's OWN generated PRDs (the canonical reuse target, written
419
+ # by the codebase-analysis prompt) do NOT use "## Feature:" markers -- they use
420
+ # sections like "## Existing Behavior and Requirements" with bullet items. So
421
+ # we must enumerate from multiple shapes, not just an explicit Feature: label:
422
+ # - explicit "Feature:"/"Requirement:" lines (any depth)
423
+ # - bullet/numbered requirement items under a requirements-like section
424
+ # - sub-section headings (### ...) that name a discrete capability
425
+ # A bare top-level document title (the single "# Title" line) is excluded.
426
+ REQ_SECTION = re.compile(r"requirement|feature|behavior|capabilit|user stor|acceptance", re.I)
427
+ NON_FEATURE = ("overview", "summary", "prd", "spec", "requirements", "features",
428
+ "scope", "goals", "detected stack", "stack", "non-goals",
429
+ "out of scope", "context", "background", "existing behavior and requirements")
430
+ in_req_section = False
431
+ for line in raw.splitlines():
432
+ s = line.strip()
433
+ if not s:
434
+ continue
435
+ # Explicit label anywhere.
436
+ m = re.match(r"^(?:[#>*\-\d.\s]+)?(?:feature|requirement)\s*[:\-]\s+(.+)$", s, re.I)
437
+ if m:
438
+ n = _norm_title(m.group(1))
439
+ if n and n not in NON_FEATURE:
440
+ titles.append(n)
441
+ continue
442
+ # Section heading: track whether we are inside a requirements-like section.
443
+ hm = re.match(r"^#{1,6}\s+(.+)$", s)
444
+ if hm:
445
+ head = _norm_title(hm.group(1))
446
+ in_req_section = bool(REQ_SECTION.search(head))
447
+ # A discrete sub-section heading (###+) that is not a known non-feature
448
+ # label is itself a feature title.
449
+ if re.match(r"^#{3,6}\s+", s) and head and head not in NON_FEATURE:
450
+ titles.append(head)
451
+ continue
452
+ # Bullet / numbered item inside a requirements-like section is a requirement.
453
+ if in_req_section:
454
+ bm = re.match(r"^(?:[-*+]|\d+[.)])\s+(.+)$", s)
455
+ if bm:
456
+ n = _norm_title(bm.group(1))
457
+ # Keep it short-ish (a requirement line, not a paragraph) and real.
458
+ if n and n not in NON_FEATURE and len(n) <= 200:
459
+ titles.append(n)
460
+ # Dedup, preserve order.
461
+ seen = set(); uniq = []
462
+ for t in titles:
463
+ if t not in seen:
464
+ seen.add(t); uniq.append(t)
465
+ return uniq if uniq else None
466
+
467
+ all_met = len(statuses) > 0 and all(s == "met" for s in statuses)
468
+ any_unmet = any(s == "unmet" for s in statuses)
469
+ any_met = any(s == "met" for s in statuses)
470
+
471
+ # Coverage check: did the model address every PRD feature? If the PRD set is
472
+ # known and the model omitted any of it, the "all met" is over a subset -> not
473
+ # trustworthy as done.
474
+ _prd_titles = prd_feature_titles(prd_path)
475
+ _covered = set(_norm_title(t) for t in (
476
+ (r.get("title") or "") for r in reqs if isinstance(r, dict)
477
+ ) if _norm_title(t))
478
+ # coverage_gap is True when we KNOW the model under-enumerated the spec.
479
+ # coverage_unknown is True when we could NOT enumerate the PRD at all -- in which
480
+ # case we CANNOT trust a done (a wrong done burns trust; a wrong incomplete only
481
+ # costs a rebuild). Per the NEGATIVE-only-fast-path rule, an inability to verify
482
+ # coverage must block done, never enable it.
483
+ coverage_gap = False
484
+ coverage_unknown = _prd_titles is None
485
+ if _prd_titles is not None:
486
+ _missing = [t for t in set(_prd_titles) if t not in _covered]
487
+ coverage_gap = len(_missing) > 0
488
+
489
+ # The model's own top-line verdict. It is NECESSARY-but-not-sufficient for done:
490
+ # the defensive re-derivation can only ever DOWNGRADE from it, never upgrade. If
491
+ # the model itself did not say done (inconclusive/incomplete), we must not fast-
492
+ # stop even when the per-requirement breakdown looks all-met -- that would
493
+ # override the model's explicit negative judgment (critical-check-1: inconclusive
494
+ # falls through to build; critical-check-2: the positive decision is the model's,
495
+ # never a deterministic all-met=>done shortcut).
496
+ obj_verdict = str(obj.get("verdict", "")).strip().lower()
497
+ model_says_done = obj_verdict == "done"
498
+
499
+ # Defensive re-derivation: the model must say done AND every objective guard must
500
+ # pass. done requires: model top-line done AND all requirements met AND tests not
501
+ # red AND no coverage gap/unknown. Any guard failing downgrades (never upgrades).
502
+ if model_says_done and all_met and axis != "red" and not coverage_gap and not coverage_unknown:
503
+ verdict = "done"
504
+ elif model_says_done and coverage_unknown and all_met and axis != "red":
505
+ # Even with a model 'done', if we cannot enumerate the PRD to confirm full
506
+ # coverage, route to inconclusive -> build (no fake-green over an unverifiable
507
+ # spec). Handled by the coverage_unknown branch below; fall through.
508
+ verdict = "inconclusive"
509
+ elif all_met and axis != "red" and not coverage_gap and not coverage_unknown and not model_says_done:
510
+ # Per-requirement breakdown looks complete but the MODEL did not declare done
511
+ # (it said inconclusive/incomplete). Respect the model: do not fast-stop.
512
+ verdict = "inconclusive"
513
+ elif coverage_unknown and all_met and axis != "red":
514
+ # We could not enumerate the PRD to confirm the model covered all of it. A
515
+ # done over an unverifiable spec is a fake-green risk (the model may have
516
+ # addressed a subset). Route to inconclusive -> build, never a fast-stop.
517
+ verdict = "inconclusive"
518
+ elif coverage_gap and all_met and axis != "red":
519
+ # Model reported all-met but did not cover every PRD feature -> it declared a
520
+ # SUBSET done. Never a fast-stop on partial coverage (no fake-green). The met
521
+ # subset can still seed an incremental build of the omitted features.
522
+ verdict = "incomplete"
523
+ elif any_unmet or (any_met and not all_met):
524
+ verdict = "incomplete"
525
+ elif all_met and axis == "red":
526
+ # Model claimed everything met but fresh tests are red -> downgrade.
527
+ verdict = "incomplete"
528
+ else:
529
+ verdict = "inconclusive"
530
+
531
+ # The `update` action's PRD is stale by definition; a fast-stop done is a
532
+ # false-stop risk. Force done -> incomplete (incremental) when any requirement
533
+ # is met, else inconclusive. NEVER a fast-stop on update.
534
+ if action == "update" and verdict == "done":
535
+ verdict = "incomplete" if any_met else "inconclusive"
536
+
537
+ reason = ""
538
+ if verdict == "inconclusive":
539
+ if not statuses:
540
+ reason = "no per-requirement evidence returned"
541
+ elif axis == "red":
542
+ reason = "fresh tests are red"
543
+ else:
544
+ reason = "could not establish ground truth"
545
+
546
+ # On incomplete we only trust the met set when the tests are not red; a red
547
+ # suite means even "met" claims are unverified, so the manifest stays empty
548
+ # (rebuild everything) rather than risk skipping broken work.
549
+ if verdict == "incomplete" and axis == "red":
550
+ satisfied = []
551
+
552
+ print(json.dumps({
553
+ "verdict": verdict,
554
+ "summary": (obj.get("summary") or "").strip(),
555
+ "reason": reason,
556
+ "tests_axis": axis,
557
+ "met_count": len([s for s in statuses if s == "met"]),
558
+ "total_count": len(statuses),
559
+ "prd_feature_count": (len(set(_prd_titles)) if _prd_titles is not None else None),
560
+ "coverage_gap": coverage_gap,
561
+ "satisfied": satisfied,
562
+ }))
563
+ DR_PARSE_EOF
564
+ )
565
+
566
+ if [ -z "$parsed" ]; then
567
+ log_info "Done-recognition: verdict parse produced no result; proceeding to build."
568
+ return 1
569
+ fi
570
+
571
+ local verdict
572
+ verdict=$(printf '%s' "$parsed" | python3 -c "import json,sys;print(json.load(sys.stdin).get('verdict',''))" 2>/dev/null)
573
+
574
+ case "$verdict" in
575
+ done)
576
+ _loki_done_recog_finish "$prd_path" "$parsed"
577
+ return 0
578
+ ;;
579
+ incomplete)
580
+ _loki_done_recog_write_manifest "$prd_path" "$parsed"
581
+ return 1
582
+ ;;
583
+ *)
584
+ local _reason
585
+ _reason=$(printf '%s' "$parsed" | python3 -c "import json,sys;print(json.load(sys.stdin).get('reason','') or 'unverifiable')" 2>/dev/null)
586
+ log_info "Done-recognition: could not confirm the existing code already satisfies the reused spec (${_reason}). Proceeding to build to be safe."
587
+ return 1
588
+ ;;
589
+ esac
590
+ }
591
+
592
+ # done path: refresh the verified-completion record (gate-owned, so it is
593
+ # deterministic even when sourced standalone in tests) and run the council-parity
594
+ # finalization subset (all type-guarded best-effort). The caller's `return 0`
595
+ # then skips the queue/loop, and main()'s terminal block finalizes the run.
596
+ _loki_done_recog_finish() {
597
+ local prd_path="$1"
598
+ local parsed="$2"
599
+ local loki_dir="${TARGET_DIR:-.}/.loki"
600
+
601
+ # The gate runs EARLY in run_autonomous, before the run normally mints these
602
+ # run-scoped ids/baselines (run.sh sets them just after this call site). The
603
+ # council-parity finalizers below (and what they transitively call) expect
604
+ # them. run.sh is under `set -u`, so mint/guard them here if-absent so a real
605
+ # done verdict never references an unbound var. Idempotent: := only sets when
606
+ # unset, so this never clobbers a value the run already minted. The if-absent
607
+ # trust-run-id mint is exactly the ordering fix the plan prescribes (no
608
+ # hoisting of run.sh's existing block, keeping the change localized).
609
+ if [ -z "${LOKI_TRUST_RUN_ID:-}" ] && type _loki_trust_run_id >/dev/null 2>&1; then
610
+ LOKI_TRUST_RUN_ID="$(_loki_trust_run_id --new 2>/dev/null || echo "")"
611
+ export LOKI_TRUST_RUN_ID
612
+ fi
613
+ : "${LOKI_TRUST_RUN_ID:=}"
614
+ : "${_LOKI_RUN_START_SHA:=}"
615
+ : "${_LOKI_RUN_START_EPOCH:=$(date +%s 2>/dev/null || echo 0)}"
616
+ export LOKI_TRUST_RUN_ID _LOKI_RUN_START_SHA _LOKI_RUN_START_EPOCH
617
+
618
+ mkdir -p "$loki_dir/state" 2>/dev/null || true
619
+
620
+ local summary met total axis
621
+ summary=$(printf '%s' "$parsed" | python3 -c "import json,sys;print(json.load(sys.stdin).get('summary','') or 'Project already satisfies its spec.')" 2>/dev/null)
622
+ met=$(printf '%s' "$parsed" | python3 -c "import json,sys;print(json.load(sys.stdin).get('met_count',0))" 2>/dev/null)
623
+ total=$(printf '%s' "$parsed" | python3 -c "import json,sys;print(json.load(sys.stdin).get('total_count',0))" 2>/dev/null)
624
+ axis=$(printf '%s' "$parsed" | python3 -c "import json,sys;print(json.load(sys.stdin).get('tests_axis','unknown'))" 2>/dev/null)
625
+ [ -n "$met" ] || met=0
626
+ [ -n "$total" ] || total=0
627
+ [ -n "$axis" ] || axis="unknown"
628
+ # HONESTY: the receipt must only claim test-backing the gate actually computed.
629
+ # A green axis means fresh tests ran AND passed now; otherwise the basis is
630
+ # code inspection alone (no runner / inconclusive) -- never overclaim.
631
+ local verify_basis
632
+ if [ "$axis" = "green" ]; then
633
+ verify_basis="re-ran the tests now (passed) and code inspection"
634
+ else
635
+ verify_basis="code inspection (no passing test run was available to confirm)"
636
+ fi
637
+
638
+ # Refresh the verified-completion record reflecting the NOW re-run results.
639
+ # Prefer the shared standalone writer (one writer, no divergence). It reads
640
+ # git/state, not loop locals, so it is safe at this pre-loop site. Wrapped
641
+ # type-guarded so the standalone test (no run.sh) still works.
642
+ if type build_completion_summary >/dev/null 2>&1; then
643
+ build_completion_summary complete || true
644
+ fi
645
+
646
+ # Gate-owned durable artifacts (always written, so the receipt + dashboard
647
+ # reflect THIS verified-done verdict and tests are deterministic). The
648
+ # per-requirement evidence is recorded as the completion-evidence body.
649
+ local ts
650
+ ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
651
+ {
652
+ echo "# Completion Evidence (reuse done-recognition)"
653
+ echo ""
654
+ echo "Generated: $ts"
655
+ echo ""
656
+ echo "Verdict: done (basis: ${verify_basis})"
657
+ echo "Requirements met: ${met}/${total}"
658
+ echo "Fresh-test axis: ${axis}"
659
+ echo ""
660
+ echo "Summary: $summary"
661
+ echo ""
662
+ echo "## Per-requirement evidence"
663
+ echo ""
664
+ printf '%s' "$parsed" | python3 -c "
665
+ import json, sys
666
+ try:
667
+ d = json.load(sys.stdin)
668
+ except Exception:
669
+ sys.exit(0)
670
+ for t in d.get('satisfied', []):
671
+ print('- met: %s' % t)
672
+ " 2>/dev/null || true
673
+ } > "$loki_dir/completion-evidence.md" 2>/dev/null || true
674
+
675
+ # Refresh completion.json INLINE (gate-owned), guaranteeing the durable
676
+ # machine-readable record exists with this verdict even when sourced
677
+ # standalone. Atomic write.
678
+ LOKI_DR_OUT="$loki_dir/state/completion.json" \
679
+ LOKI_DR_SUMMARY="$summary" \
680
+ LOKI_DR_MET="$met" \
681
+ LOKI_DR_TOTAL="$total" \
682
+ LOKI_DR_TS="$ts" \
683
+ python3 -c "
684
+ import json, os, tempfile
685
+ out = os.environ['LOKI_DR_OUT']
686
+ def i(v):
687
+ try: return int(v)
688
+ except (TypeError, ValueError): return 0
689
+ rec = {
690
+ 'outcome': 'complete',
691
+ 'source': 'reuse-done-recognition',
692
+ 'verdict': 'done',
693
+ 'summary': os.environ.get('LOKI_DR_SUMMARY', ''),
694
+ 'requirements_met': i(os.environ.get('LOKI_DR_MET')),
695
+ 'requirements_total': i(os.environ.get('LOKI_DR_TOTAL')),
696
+ 'verified_at': os.environ.get('LOKI_DR_TS', ''),
697
+ }
698
+ d = os.path.dirname(os.path.abspath(out)) or '.'
699
+ try:
700
+ os.makedirs(d, exist_ok=True)
701
+ fd, tmp = tempfile.mkstemp(dir=d, prefix='.completion-', suffix='.json')
702
+ with os.fdopen(fd, 'w') as f:
703
+ json.dump(rec, f, indent=2)
704
+ os.replace(tmp, out)
705
+ except Exception:
706
+ pass
707
+ " 2>/dev/null || true
708
+
709
+ # COMPLETED marker (gate-owned; idempotent overwrite, precedented by the
710
+ # council force-approve path at run.sh:17692). main() also writes it.
711
+ echo "Project already satisfied its spec (reuse done-recognition) at $ts" \
712
+ > "$loki_dir/COMPLETED" 2>/dev/null || true
713
+
714
+ # Council-parity finalization subset (mirrors run.sh:17693-17703). All
715
+ # type-guarded so the standalone test needs zero stubs; production gets full
716
+ # parity. main() owns _advance_current_phase COMPLETED + proof + handoff.
717
+ type council_write_report >/dev/null 2>&1 && council_write_report || true
718
+ type run_memory_consolidation >/dev/null 2>&1 && run_memory_consolidation || true
719
+ type on_run_complete >/dev/null 2>&1 && on_run_complete || true
720
+ type emit_completion_summary >/dev/null 2>&1 && emit_completion_summary complete || true
721
+ type save_state >/dev/null 2>&1 && save_state "${RETRY_COUNT:-0}" "reuse_already_satisfied" 0 || true
722
+
723
+ # User-facing message (enterprise UX). The last line names BOTH escape
724
+ # hatches so a user who WANTS to extend a done project sees them unmissably.
725
+ log_header "This project already satisfies its spec. Nothing to build." 2>/dev/null \
726
+ || log_info "This project already satisfies its spec. Nothing to build."
727
+ if [ "$axis" = "green" ]; then
728
+ log_info "Verified ${met}/${total} requirements met and re-ran the tests now (passed). ${summary}"
729
+ else
730
+ log_info "Verified ${met}/${total} requirements met by code inspection (no passing test run was available to confirm). ${summary}"
731
+ fi
732
+ log_info "To rebuild from scratch run 'loki start --fresh-prd'; to extend it, edit the spec or pass a new/changed PRD."
733
+ }
734
+
735
+ # incomplete path: write the satisfied-requirements manifest so
736
+ # populate_prd_queue skips already-met features. prd_sha-guarded; keyed on
737
+ # feature TITLE (matched case-insensitively in the builder).
738
+ _loki_done_recog_write_manifest() {
739
+ local prd_path="$1"
740
+ local parsed="$2"
741
+ local loki_dir="${TARGET_DIR:-.}/.loki"
742
+
743
+ mkdir -p "$loki_dir/state" 2>/dev/null || true
744
+
745
+ local prd_sha
746
+ prd_sha=$(_loki_done_recog_prd_sha "$prd_path")
747
+ local ts
748
+ ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
749
+
750
+ LOKI_DR_OUT="$loki_dir/state/satisfied-requirements.json" \
751
+ LOKI_DR_PARSED="$parsed" \
752
+ LOKI_DR_SHA="$prd_sha" \
753
+ LOKI_DR_TS="$ts" \
754
+ python3 -c "
755
+ import json, os, sys, tempfile
756
+ out = os.environ['LOKI_DR_OUT']
757
+ try:
758
+ parsed = json.loads(os.environ.get('LOKI_DR_PARSED', '{}'))
759
+ except Exception:
760
+ parsed = {}
761
+ satisfied = parsed.get('satisfied', [])
762
+ if not isinstance(satisfied, list):
763
+ satisfied = []
764
+ rec = {
765
+ 'prd_sha': os.environ.get('LOKI_DR_SHA', ''),
766
+ 'generated_at': os.environ.get('LOKI_DR_TS', ''),
767
+ 'satisfied': [s for s in satisfied if isinstance(s, str) and s.strip()],
768
+ 'source': 'reuse-done-recognition',
769
+ }
770
+ d = os.path.dirname(os.path.abspath(out)) or '.'
771
+ try:
772
+ os.makedirs(d, exist_ok=True)
773
+ fd, tmp = tempfile.mkstemp(dir=d, prefix='.satisfied-', suffix='.json')
774
+ with os.fdopen(fd, 'w') as f:
775
+ json.dump(rec, f, indent=2)
776
+ os.replace(tmp, out)
777
+ except Exception:
778
+ pass
779
+ " 2>/dev/null || true
780
+
781
+ # The satisfied-requirements manifest is read by populate_prd_queue's
782
+ # incremental skip-filter -- but populate_prd_queue early-returns when a stale
783
+ # .prd-populated marker from the PRIOR (completed) run still exists, never
784
+ # reaching the filter. Clear that marker (and reset the pending queue) so the
785
+ # incremental rebuild actually runs and skips the satisfied features. Without
786
+ # this, the founder-locked "build only the unsatisfied gap" behavior is inert.
787
+ # Use $loki_dir (TARGET_DIR-rooted) for the queue paths too, matching the
788
+ # manifest path above. run_autonomous runs with cwd==TARGET_DIR so the prior
789
+ # relative form resolved identically, but a single rooted convention avoids a
790
+ # latent cwd footgun.
791
+ rm -f "$loki_dir/queue/.prd-populated" 2>/dev/null || true
792
+ if [ -f "$loki_dir/queue/pending.json" ]; then
793
+ # Drop prior PRD-sourced tasks so the incremental pass is the source of
794
+ # truth; non-PRD tasks (if any) are preserved.
795
+ LOKI_DR_PENDING="$loki_dir/queue/pending.json" python3 - <<'RESET_EOF' 2>/dev/null || true
796
+ import json, os, tempfile
797
+ p = os.environ.get("LOKI_DR_PENDING", ".loki/queue/pending.json")
798
+ try:
799
+ with open(p) as f:
800
+ data = json.load(f)
801
+ except Exception:
802
+ raise SystemExit(0)
803
+ tasks = data.get("tasks", data) if isinstance(data, dict) else data
804
+ if isinstance(tasks, list):
805
+ kept = [t for t in tasks if not (isinstance(t, dict) and str(t.get("id", "")).startswith("prd-"))]
806
+ if isinstance(data, dict):
807
+ data["tasks"] = kept
808
+ else:
809
+ data = kept
810
+ d = os.path.dirname(os.path.abspath(p)) or "."
811
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".pending-", suffix=".json")
812
+ with os.fdopen(fd, "w") as f:
813
+ json.dump(data, f, indent=2)
814
+ os.replace(tmp, p)
815
+ RESET_EOF
816
+ fi
817
+
818
+ local met total
819
+ met=$(printf '%s' "$parsed" | python3 -c "import json,sys;print(json.load(sys.stdin).get('met_count',0))" 2>/dev/null)
820
+ total=$(printf '%s' "$parsed" | python3 -c "import json,sys;print(json.load(sys.stdin).get('total_count',0))" 2>/dev/null)
821
+ local unmet=$(( ${total:-0} - ${met:-0} ))
822
+ log_info "Done-recognition: ${met:-0} of ${total:-0} requirements already satisfied; building only the ${unmet} unmet. Pass --fresh-prd to rebuild from scratch."
823
+ }