loki-mode 7.55.0 → 7.57.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 +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +101 -0
- package/autonomy/lib/prd-enrich.sh +437 -0
- package/autonomy/loki +244 -8
- package/autonomy/run.sh +175 -60
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +382 -2
- package/dashboard/static/index.html +164 -151
- package/docs/INSTALLATION.md +2 -2
- 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
- package/skills/quality-gates.md +135 -11
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) | `--
|
|
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.
|
|
6
|
+
# Loki Mode v7.57.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.
|
|
409
|
+
**v7.57.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.57.0
|
package/autonomy/app-runner.sh
CHANGED
|
@@ -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
|
+
}
|