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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/done-recognition.sh +823 -0
- package/autonomy/run.sh +74 -1
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/PRD-REUSE-DONE-RECOGNITION-PLAN.md +536 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
|
@@ -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
|
+
}
|