loki-mode 7.56.0 → 7.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -322,7 +322,7 @@ Loki's autonomy and quality loop are the product; the underlying coding CLI is s
322
322
  | Provider | Status | Autonomous Flag | Parallel Agents | Install |
323
323
  |----------|--------|:-:|:-:|---------|
324
324
  | **Claude Code** | Active (Tier 1, E2E-verified) | `--dangerously-skip-permissions` | Yes (10+) | `npm i -g @anthropic-ai/claude-code` |
325
- | **Codex CLI** | Experimental (Tier 3) | `--full-auto --skip-git-repo-check` | Sequential | `npm i -g @openai/codex` |
325
+ | **Codex CLI** | Experimental (Tier 3) | `--sandbox workspace-write --skip-git-repo-check` | Sequential | `npm i -g @openai/codex` |
326
326
  | **Cline CLI** | Experimental (Tier 2) | `-y` | Sequential | `npm i -g @anthropic-ai/cline` |
327
327
  | **Aider** | Experimental (Tier 3) | `--yes-always` | Sequential | `pip install aider-chat` |
328
328
  | **Google Gemini CLI** | DEPRECATED v7.5.18 | -- | -- | Upstream deprecated; runtime removed. `LOKI_PROVIDER=gemini` exits with migration message. |
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.56.0
6
+ # Loki Mode v7.58.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
406
406
 
407
407
  ---
408
408
 
409
- **v7.56.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.58.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.56.0
1
+ 7.58.0
@@ -109,11 +109,19 @@ _write_app_state() {
109
109
  method_escaped=$(_json_escape "${_APP_RUNNER_METHOD}")
110
110
  local url_escaped
111
111
  url_escaped=$(_json_escape "${_APP_RUNNER_URL}")
112
+ # v7.51.x: persist the identified primary web service so app-runner-managed
113
+ # compose runs expose the SAME field the dashboard's compose-stack discovery
114
+ # synthesizes (primary_service). Empty for non-compose runs (the global is
115
+ # only set for compose), which is additive and harmless: the field is present
116
+ # but blank, never absent, so consumers can read it uniformly.
117
+ local primary_service_escaped
118
+ primary_service_escaped=$(_json_escape "${_APP_RUNNER_WEB_SERVICE}")
112
119
  cat > "$tmp_file" << APPSTATE_EOF
113
120
  {
114
121
  "main_pid": ${_APP_RUNNER_PID:-0},
115
122
  "process_group": "-${_APP_RUNNER_PID:-0}",
116
123
  "method": "${method_escaped}",
124
+ "primary_service": "${primary_service_escaped}",
117
125
  "port": $(echo "${_APP_RUNNER_PORT:-0}" | grep -oE '^[0-9]+$' || echo 0),
118
126
  "url": "${url_escaped}",
119
127
  "started_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
@@ -842,6 +850,82 @@ _app_runner_compose_running_count() {
842
850
  return 0
843
851
  }
844
852
 
853
+ # Read the RUNTIME published host port of the identified primary web service from
854
+ # `docker compose ps` (the live mapping), as opposed to the config-declared port
855
+ # from `docker compose config`. The config port is correct for fixed mappings
856
+ # (e.g. ports: ["8080:80"]) but wrong when the host side is ephemeral/random
857
+ # (ports: ["80"], published: 0, or a range), where Docker assigns the host port
858
+ # only at run time. Parses `docker compose ps --format json` with python3 (we
859
+ # already depend on python3 and on reading `docker compose ps` for health), and
860
+ # echoes the published host port of the web service, or nothing on any failure
861
+ # (caller keeps the recorded port -- no regression). Never hard-fails. Docker's
862
+ # own published mapping is authoritative, so no curl liveness guard is needed
863
+ # here (unlike the non-docker _app_runner_reconcile_port path).
864
+ _app_runner_compose_published_port() {
865
+ local base="${1:-${TARGET_DIR:-.}}"
866
+ local service="${2:-}"
867
+ [ -n "$service" ] || return 0
868
+ command -v docker >/dev/null 2>&1 || return 0
869
+ command -v python3 >/dev/null 2>&1 || return 0
870
+ local compose_dir
871
+ compose_dir=$(_app_runner_compose_dir "$base")
872
+ local ps_json
873
+ # `docker compose ps --format json` emits either a JSON array or one JSON
874
+ # object per line (NDJSON) depending on the compose version; the parser below
875
+ # handles both shapes.
876
+ ps_json=$(cd "$compose_dir" && docker compose ps --format json 2>/dev/null) || return 0
877
+ [ -n "$ps_json" ] || return 0
878
+ printf '%s' "$ps_json" | LOKI_WEB_SERVICE="$service" python3 -c '
879
+ import json, os, sys
880
+ svc = os.environ.get("LOKI_WEB_SERVICE", "")
881
+ raw = sys.stdin.read().strip()
882
+ if not raw or not svc:
883
+ sys.exit(0)
884
+
885
+ # Accept a JSON array OR newline-delimited JSON objects.
886
+ entries = []
887
+ try:
888
+ parsed = json.loads(raw)
889
+ entries = parsed if isinstance(parsed, list) else [parsed]
890
+ except Exception:
891
+ for line in raw.splitlines():
892
+ line = line.strip()
893
+ if not line:
894
+ continue
895
+ try:
896
+ entries.append(json.loads(line))
897
+ except Exception:
898
+ pass
899
+
900
+ def published_for(entry):
901
+ # docker compose ps json exposes published ports under "Publishers", each a
902
+ # dict with "PublishedPort" (host port, 0 when not published to the host).
903
+ ports = []
904
+ for pub in (entry.get("Publishers") or []):
905
+ if not isinstance(pub, dict):
906
+ continue
907
+ pp = pub.get("PublishedPort")
908
+ try:
909
+ pp = int(pp)
910
+ except (TypeError, ValueError):
911
+ continue
912
+ if 1 <= pp <= 65535:
913
+ ports.append(pp)
914
+ return ports
915
+
916
+ for entry in entries:
917
+ if not isinstance(entry, dict):
918
+ continue
919
+ if entry.get("Service") != svc:
920
+ continue
921
+ ports = published_for(entry)
922
+ if ports:
923
+ print(ports[0])
924
+ sys.exit(0)
925
+ sys.exit(0)
926
+ ' 2>/dev/null || return 0
927
+ }
928
+
845
929
  #===============================================================================
846
930
  # Lifecycle
847
931
  #===============================================================================
@@ -933,6 +1017,23 @@ app_runner_start() {
933
1017
  local running_containers
934
1018
  running_containers=$(_app_runner_compose_running_count "$dir")
935
1019
  if [ "${running_containers:-0}" -gt 0 ]; then
1020
+ # Reconcile the recorded port with the RUNTIME published host port of
1021
+ # the primary web service (from `docker compose ps`), so the preview
1022
+ # URL and state.json point at the real host port even when the host
1023
+ # side was ephemeral/random in the compose file. The config-declared
1024
+ # port from _detect_port is kept when no valid runtime port is found
1025
+ # (no regression). Docker's published mapping is authoritative, so no
1026
+ # curl liveness guard is needed (cf. _app_runner_reconcile_port).
1027
+ if [ -n "${_APP_RUNNER_WEB_SERVICE:-}" ]; then
1028
+ local _rt_port
1029
+ _rt_port=$(_app_runner_compose_published_port "$dir" "$_APP_RUNNER_WEB_SERVICE")
1030
+ if [ -n "$_rt_port" ] && [[ "$_rt_port" =~ ^[0-9]+$ ]] && \
1031
+ [ "$_rt_port" != "${_APP_RUNNER_PORT:-}" ]; then
1032
+ log_info "App Runner: reconciled compose port ${_APP_RUNNER_PORT:-?} -> $_rt_port (runtime published mapping for service $_APP_RUNNER_WEB_SERVICE)"
1033
+ _APP_RUNNER_PORT="$_rt_port"
1034
+ _APP_RUNNER_URL="http://localhost:${_rt_port}"
1035
+ fi
1036
+ fi
936
1037
  _write_app_state "running"
937
1038
  log_info "App Runner: docker compose started ($running_containers container(s) running)"
938
1039
  return 0
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env bash
2
+ # prd-enrich.sh -- LLM enrichment post-pass for PRD-parsed task queues.
3
+ #
4
+ # Problem: the python heredoc in run.sh::populate_prd_queue has no model
5
+ # access (json/re/os/sys only). When a PRD section has no body text, the
6
+ # deterministic parser falls back to description == title and a templated
7
+ # constant user_story. The dashboard task modal then shows placeholder junk.
8
+ #
9
+ # This module runs AFTER pending.json is written. For source=="prd" tasks
10
+ # that still carry a stub (description == title, or the templated user_story
11
+ # constant), it builds ONE batched provider call that passes the task titles
12
+ # plus the full PRD context and asks for a JSON array of enriched fields. The
13
+ # response is merged back into pending.json atomically (temp file + mv),
14
+ # preserving the on-disk format (bare list or {"tasks":[...]} wrapper).
15
+ #
16
+ # GRACEFUL FALLBACK: if the provider is unavailable (non-claude provider,
17
+ # PROVIDER_DEGRADED, claude binary absent, the call times out / fails, or the
18
+ # JSON cannot be parsed) the function leaves the deterministic output intact
19
+ # and returns 0. Queue population is NEVER blocked on the LLM.
20
+ #
21
+ # Contract:
22
+ # loki_prd_enrich <pending_json_path> <prd_file_path>
23
+ # - returns 0 always (best-effort enrichment; never fails the caller)
24
+ # - mutates <pending_json_path> in place only on a successful enrichment
25
+ #
26
+ # Indirection (for testability): the actual model call goes through
27
+ # _loki_prd_enrich_invoke <prompt> (echoes the raw model response)
28
+ # Tests stub this function to return a fixed JSON array without a real model.
29
+ #
30
+ # No emojis. No em dashes. bash 3.2 safe. Honors `set -uo pipefail`.
31
+
32
+ # Bound the batched call so a huge PRD or task list cannot run away.
33
+ : "${LOKI_PRD_ENRICH_TIMEOUT:=120}" # seconds for the single model call
34
+ : "${LOKI_PRD_ENRICH_MAX_TASKS:=40}" # cap tasks sent in one batch
35
+ : "${LOKI_PRD_ENRICH_MAX_PRD_CHARS:=12000}" # cap PRD context length
36
+
37
+ # The single model-call primitive. Kept as its own function so:
38
+ # 1. it is the ONE place that touches the provider, and
39
+ # 2. tests can override it to return canned JSON.
40
+ # Calls `claude -p` directly (not the provider_invoke shell function) because
41
+ # `timeout` needs a real command, not a function. Mirrors the in-tree
42
+ # precedent at autonomy/lib/voter-agents.sh:259.
43
+ _loki_prd_enrich_invoke() {
44
+ local prompt="$1"
45
+ command -v claude >/dev/null 2>&1 || return 1
46
+ local rc=0
47
+ local out=""
48
+ if command -v timeout >/dev/null 2>&1; then
49
+ out=$(CAVEMAN_DEFAULT_MODE=off timeout "${LOKI_PRD_ENRICH_TIMEOUT}" \
50
+ claude --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
51
+ else
52
+ # No coreutils timeout (e.g. bare macOS). Run without it; the model
53
+ # call is a one-shot and the caller still tolerates failure.
54
+ out=$(CAVEMAN_DEFAULT_MODE=off \
55
+ claude --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
56
+ fi
57
+ [ "$rc" -ne 0 ] && return 1
58
+ [ -z "$out" ] && return 1
59
+ printf '%s' "$out"
60
+ return 0
61
+ }
62
+
63
+ # Decide whether enrichment should even be attempted. Returns 0 (attempt)
64
+ # only when the active provider is claude and not in degraded mode.
65
+ _loki_prd_enrich_provider_ok() {
66
+ [ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
67
+ [ "${PROVIDER_DEGRADED:-false}" != "true" ] || return 1
68
+ command -v claude >/dev/null 2>&1 || return 1
69
+ return 0
70
+ }
71
+
72
+ # Deterministic (no-model) enrichment. Replaces the templated user_story
73
+ # constant (run.sh:13614) with a content-derived one, varying by the task's
74
+ # own body text. Description is left to Bug-A (already real where body exists).
75
+ # Pure python (json/re/os only): safe on any provider, offline, or air-gapped.
76
+ # Always atomic (temp + replace), best-effort, returns 0.
77
+ _loki_prd_enrich_deterministic() {
78
+ local pending_path="${1:-}"
79
+ [ -n "$pending_path" ] && [ -f "$pending_path" ] || return 0
80
+ LOKI_PE_PENDING="$pending_path" python3 << 'PE_DET_EOF'
81
+ import json, os, re, sys, tempfile
82
+
83
+ pending = os.environ.get("LOKI_PE_PENDING", "")
84
+ if not pending or not os.path.isfile(pending):
85
+ sys.exit(0)
86
+
87
+ try:
88
+ with open(pending, "r") as f:
89
+ data = json.load(f)
90
+ except Exception:
91
+ sys.exit(0)
92
+
93
+ if isinstance(data, list):
94
+ tasks = data
95
+ wrapper = None
96
+ elif isinstance(data, dict):
97
+ tasks = data.get("tasks", [])
98
+ wrapper = data
99
+ else:
100
+ sys.exit(0)
101
+
102
+ def is_stub_user_story(s):
103
+ return isinstance(s, str) and s.endswith("so that the product delivers its core value.")
104
+
105
+ def first_sentence(text, limit=160):
106
+ text = re.sub(r"\s+", " ", (text or "").strip())
107
+ if not text:
108
+ return ""
109
+ m = re.search(r"(.+?[.!?])(\s|$)", text)
110
+ s = m.group(1) if m else text
111
+ return s[:limit].strip()
112
+
113
+ changed = 0
114
+ for t in tasks:
115
+ if not isinstance(t, dict):
116
+ continue
117
+ if t.get("source") != "prd":
118
+ continue
119
+ if not is_stub_user_story(t.get("user_story")):
120
+ continue
121
+ title = (t.get("title") or "").strip()
122
+ if not title:
123
+ continue
124
+ # Derive a benefit clause from the section body (description minus the
125
+ # leading title line), falling back to the first acceptance criterion.
126
+ desc = (t.get("description") or "").strip()
127
+ body = desc
128
+ if body.startswith(title):
129
+ body = body[len(title):].strip()
130
+ benefit = first_sentence(body)
131
+ if not benefit:
132
+ ac = t.get("acceptance_criteria")
133
+ if isinstance(ac, list) and ac:
134
+ benefit = first_sentence(str(ac[0]))
135
+ cap = title[:1].lower() + title[1:] if title else title
136
+ if benefit:
137
+ t["user_story"] = "As a user, I want %s, so that %s" % (
138
+ cap.rstrip("."),
139
+ benefit[:1].lower() + benefit[1:] if benefit else benefit,
140
+ )
141
+ if not t["user_story"].endswith("."):
142
+ t["user_story"] += "."
143
+ else:
144
+ # No body text at all: still drop the generic constant for a
145
+ # capability-specific phrasing.
146
+ t["user_story"] = "As a user, I want %s, so that I can use this capability." % cap.rstrip(".")
147
+ changed += 1
148
+
149
+ if changed == 0:
150
+ sys.exit(0)
151
+
152
+ if wrapper is not None:
153
+ wrapper["tasks"] = tasks
154
+ output = wrapper
155
+ else:
156
+ output = tasks
157
+
158
+ d = os.path.dirname(os.path.abspath(pending)) or "."
159
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".pending-det-", suffix=".json")
160
+ try:
161
+ with os.fdopen(fd, "w") as f:
162
+ json.dump(output, f, indent=2)
163
+ os.replace(tmp, pending)
164
+ except Exception:
165
+ try:
166
+ os.unlink(tmp)
167
+ except Exception:
168
+ pass
169
+ sys.exit(0)
170
+ PE_DET_EOF
171
+ return 0
172
+ }
173
+
174
+ loki_prd_enrich() {
175
+ local pending_path="${1:-}"
176
+ local prd_path="${2:-}"
177
+
178
+ [ -n "$pending_path" ] && [ -f "$pending_path" ] || return 0
179
+ [ -n "$prd_path" ] && [ -f "$prd_path" ] || return 0
180
+
181
+ # Provider gate. On a degraded / non-claude / no-binary provider we still
182
+ # improve tasks deterministically (content-derived user_story) so even
183
+ # offline users get informative tasks, then return without a model call.
184
+ if ! _loki_prd_enrich_provider_ok; then
185
+ _loki_prd_enrich_deterministic "$pending_path"
186
+ return 0
187
+ fi
188
+
189
+ # Stage 1 (python): identify stub prd tasks and emit a compact JSON
190
+ # payload {tasks:[{id,title}], prd:"..."} for the model. If there is
191
+ # nothing to enrich, emit empty and we return early.
192
+ local payload
193
+ payload=$(LOKI_PE_PENDING="$pending_path" LOKI_PE_PRD="$prd_path" \
194
+ LOKI_PE_MAX_TASKS="${LOKI_PRD_ENRICH_MAX_TASKS}" \
195
+ LOKI_PE_MAX_PRD="${LOKI_PRD_ENRICH_MAX_PRD_CHARS}" \
196
+ python3 << 'PE_PAYLOAD_EOF'
197
+ import json, os, sys
198
+
199
+ pending = os.environ.get("LOKI_PE_PENDING", "")
200
+ prd = os.environ.get("LOKI_PE_PRD", "")
201
+ max_tasks = int(os.environ.get("LOKI_PE_MAX_TASKS", "40") or "40")
202
+ max_prd = int(os.environ.get("LOKI_PE_MAX_PRD", "12000") or "12000")
203
+
204
+ try:
205
+ with open(pending, "r") as f:
206
+ data = json.load(f)
207
+ except Exception:
208
+ sys.exit(0)
209
+
210
+ if isinstance(data, list):
211
+ tasks = data
212
+ elif isinstance(data, dict):
213
+ tasks = data.get("tasks", [])
214
+ else:
215
+ sys.exit(0)
216
+
217
+ # Templated constant produced by run.sh:13614 (the sentinel we replace).
218
+ def is_stub_user_story(s):
219
+ if not isinstance(s, str):
220
+ return False
221
+ return s.endswith("so that the product delivers its core value.")
222
+
223
+ stubs = []
224
+ for t in tasks:
225
+ if not isinstance(t, dict):
226
+ continue
227
+ if t.get("source") != "prd":
228
+ continue
229
+ title = (t.get("title") or "").strip()
230
+ desc = (t.get("description") or "").strip()
231
+ us = t.get("user_story") or ""
232
+ if not title:
233
+ continue
234
+ # Stub = description is just the title, OR the templated user_story.
235
+ if desc == title or is_stub_user_story(us):
236
+ stubs.append({"id": t.get("id"), "title": title})
237
+
238
+ if not stubs:
239
+ sys.exit(0)
240
+
241
+ stubs = stubs[:max_tasks]
242
+
243
+ try:
244
+ with open(prd, "r", errors="replace") as f:
245
+ prd_text = f.read()
246
+ except Exception:
247
+ prd_text = ""
248
+ prd_text = prd_text[:max_prd]
249
+
250
+ print(json.dumps({"tasks": stubs, "prd": prd_text}))
251
+ PE_PAYLOAD_EOF
252
+ )
253
+
254
+ # Nothing to enrich, or payload generation failed -> keep deterministic.
255
+ [ -n "$payload" ] || return 0
256
+
257
+ # Stage 2: build the batched prompt and call the model.
258
+ local task_lines prd_context
259
+ task_lines=$(printf '%s' "$payload" | python3 -c '
260
+ import json, sys
261
+ try:
262
+ d = json.load(sys.stdin)
263
+ except Exception:
264
+ sys.exit(0)
265
+ for t in d.get("tasks", []):
266
+ print("- id=%s | %s" % (t.get("id"), t.get("title")))
267
+ ' 2>/dev/null)
268
+ prd_context=$(printf '%s' "$payload" | python3 -c '
269
+ import json, sys
270
+ try:
271
+ d = json.load(sys.stdin)
272
+ except Exception:
273
+ sys.exit(0)
274
+ sys.stdout.write(d.get("prd", ""))
275
+ ' 2>/dev/null)
276
+
277
+ [ -n "$task_lines" ] || return 0
278
+
279
+ local prompt
280
+ prompt=$(cat <<PE_PROMPT_EOF
281
+ You are enriching a software task backlog so each task carries real, useful
282
+ information for an engineer. Below is the source PRD followed by a list of
283
+ tasks (each with an id and title) whose descriptions are currently empty or
284
+ generic. For EACH task id, produce informative fields grounded in the PRD.
285
+
286
+ Return ONLY a JSON array (no prose, no markdown fences). Each element:
287
+ {
288
+ "id": "<the exact task id given>",
289
+ "description": "<2-4 concrete sentences describing what to build and why, grounded in the PRD>",
290
+ "acceptance_criteria": ["<concrete, testable bullet>", "..."],
291
+ "user_story": "As <specific persona>, I want <capability>, so that <real benefit>."
292
+ }
293
+ Rules: use only ids from the list. Keep descriptions specific to the PRD, not
294
+ boilerplate. 3-6 acceptance_criteria per task. No emojis. No em dashes.
295
+
296
+ === PRD ===
297
+ ${prd_context}
298
+
299
+ === TASKS TO ENRICH ===
300
+ ${task_lines}
301
+ PE_PROMPT_EOF
302
+ )
303
+
304
+ local response
305
+ # Model call failed (offline / timeout / non-zero) -> fall back to the
306
+ # deterministic content-derived enrichment instead of leaving the stub.
307
+ response=$(_loki_prd_enrich_invoke "$prompt") || { _loki_prd_enrich_deterministic "$pending_path"; return 0; }
308
+ if [ -z "$response" ]; then
309
+ _loki_prd_enrich_deterministic "$pending_path"
310
+ return 0
311
+ fi
312
+
313
+ # Stage 3 (python): parse the model JSON defensively and merge into
314
+ # pending.json, preserving on-disk format. Write to a temp file and mv
315
+ # for atomicity. Any failure leaves the original file untouched.
316
+ LOKI_PE_PENDING="$pending_path" LOKI_PE_RESP="$response" python3 << 'PE_MERGE_EOF'
317
+ import json, os, sys, tempfile
318
+
319
+ pending = os.environ.get("LOKI_PE_PENDING", "")
320
+ resp = os.environ.get("LOKI_PE_RESP", "")
321
+
322
+ if not pending or not os.path.isfile(pending):
323
+ sys.exit(0)
324
+
325
+ # Defensive parse: slice first '[' to last ']' to tolerate prose/fences.
326
+ def parse_array(text):
327
+ try:
328
+ return json.loads(text)
329
+ except Exception:
330
+ pass
331
+ start = text.find("[")
332
+ end = text.rfind("]")
333
+ if start == -1 or end == -1 or end <= start:
334
+ return None
335
+ try:
336
+ v = json.loads(text[start:end + 1])
337
+ return v
338
+ except Exception:
339
+ return None
340
+
341
+ enriched = parse_array(resp)
342
+ if not isinstance(enriched, list) or not enriched:
343
+ sys.exit(0)
344
+
345
+ # Index enrichment by id.
346
+ by_id = {}
347
+ for e in enriched:
348
+ if not isinstance(e, dict):
349
+ continue
350
+ eid = e.get("id")
351
+ if eid:
352
+ by_id[eid] = e
353
+
354
+ if not by_id:
355
+ sys.exit(0)
356
+
357
+ try:
358
+ with open(pending, "r") as f:
359
+ data = json.load(f)
360
+ except Exception:
361
+ sys.exit(0)
362
+
363
+ if isinstance(data, list):
364
+ tasks = data
365
+ wrapper = None
366
+ elif isinstance(data, dict):
367
+ tasks = data.get("tasks", [])
368
+ wrapper = data
369
+ else:
370
+ sys.exit(0)
371
+
372
+ def clean_str(v):
373
+ if not isinstance(v, str):
374
+ return None
375
+ v = v.strip()
376
+ return v or None
377
+
378
+ merged = 0
379
+ for t in tasks:
380
+ if not isinstance(t, dict):
381
+ continue
382
+ if t.get("source") != "prd":
383
+ continue
384
+ tid = t.get("id")
385
+ e = by_id.get(tid)
386
+ if not e:
387
+ continue
388
+ title = (t.get("title") or "").strip()
389
+ # description: overwrite only with a real, non-title value.
390
+ d = clean_str(e.get("description"))
391
+ if d and d != title:
392
+ t["description"] = d
393
+ # acceptance_criteria: accept a non-empty list of non-empty strings.
394
+ ac = e.get("acceptance_criteria")
395
+ if isinstance(ac, list):
396
+ ac = [c.strip() for c in ac if isinstance(c, str) and c.strip()]
397
+ if ac:
398
+ t["acceptance_criteria"] = ac[:10]
399
+ # user_story: overwrite with a real "As ..., I want ..., so that ...".
400
+ us = clean_str(e.get("user_story"))
401
+ if us:
402
+ t["user_story"] = us
403
+ merged += 1
404
+
405
+ if merged == 0:
406
+ sys.exit(0)
407
+
408
+ if wrapper is not None:
409
+ wrapper["tasks"] = tasks
410
+ output = wrapper
411
+ else:
412
+ output = tasks
413
+
414
+ # Atomic write: temp file in the same dir, then mv.
415
+ d = os.path.dirname(os.path.abspath(pending)) or "."
416
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".pending-enrich-", suffix=".json")
417
+ try:
418
+ with os.fdopen(fd, "w") as f:
419
+ json.dump(output, f, indent=2)
420
+ os.replace(tmp, pending)
421
+ except Exception:
422
+ try:
423
+ os.unlink(tmp)
424
+ except Exception:
425
+ pass
426
+ sys.exit(0)
427
+
428
+ print("Enriched %d PRD task(s) via LLM" % merged, file=sys.stderr)
429
+ PE_MERGE_EOF
430
+
431
+ # Final deterministic sweep: any task the model did not cover (parse fail,
432
+ # missing id) still carries the templated user_story constant. Replace it
433
+ # with a content-derived one so no task is left with placeholder text.
434
+ _loki_prd_enrich_deterministic "$pending_path"
435
+
436
+ return 0
437
+ }