loki-mode 7.27.0 → 7.28.1
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 +3 -2
- package/SKILL.md +11 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +285 -6
- package/autonomy/context-tracker.py +32 -7
- package/autonomy/grill.sh +339 -0
- package/autonomy/loki +49 -0
- package/autonomy/prd-checklist.sh +248 -14
- package/autonomy/run.sh +170 -27
- package/autonomy/spec.sh +646 -0
- package/autonomy/verify.sh +55 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +2 -1
- package/skills/quality-gates.md +46 -0
package/autonomy/spec.sh
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# autonomy/spec.sh - Loki living-spec core (loki spec).
|
|
3
|
+
#
|
|
4
|
+
# "The spec is the contract; we keep it true."
|
|
5
|
+
#
|
|
6
|
+
# This is a STANDALONE module (like autonomy/verify.sh). It deliberately does
|
|
7
|
+
# NOT source run.sh: the in-loop GENERATED_PRD reconcile (run.sh:10432/:10439)
|
|
8
|
+
# is welded to the autonomous build loop, fires only as an LLM prompt, and
|
|
9
|
+
# silently rewrites the PRD without producing a divergence report a human can
|
|
10
|
+
# read or a gate can block on (see internal/SDD-PANEL-B.md section 2).
|
|
11
|
+
#
|
|
12
|
+
# What this module adds (the gap Panel B identified):
|
|
13
|
+
# - A deterministic spec-to-content binding artifact (.loki/spec/spec.lock):
|
|
14
|
+
# per-requirement content hashes of the spec sections, plus repo HEAD at
|
|
15
|
+
# lock time. No LLM pass needed to answer "has the spec gone stale".
|
|
16
|
+
# - Cheap drift detection: `loki spec status` recomputes hashes and reports
|
|
17
|
+
# ADDED / REMOVED / CHANGED requirements, plus whether code changed since
|
|
18
|
+
# the lock (diff stat vs the locked HEAD). Deterministic, no LLM cost.
|
|
19
|
+
# - A machine-readable trust artifact (.loki/spec/drift-report.json) that
|
|
20
|
+
# plugs straight into `loki verify` as a SPEC_DRIFT finding.
|
|
21
|
+
# - `loki spec sync`: explicit human action that refreshes the lock after a
|
|
22
|
+
# review. This MVP NEVER auto-rewrites the spec itself.
|
|
23
|
+
#
|
|
24
|
+
# Subcommands:
|
|
25
|
+
# loki spec lock build/refresh .loki/spec/spec.lock from the spec
|
|
26
|
+
# loki spec status cheap drift detection vs the lock (exit 0 in-sync, 1 drift)
|
|
27
|
+
# loki spec sync refresh the lock after review (alias semantics of lock,
|
|
28
|
+
# named distinctly so the human-review intent is explicit)
|
|
29
|
+
#
|
|
30
|
+
# Spec source resolution (first match wins):
|
|
31
|
+
# 1. explicit path argument
|
|
32
|
+
# 2. .loki/generated-prd.md
|
|
33
|
+
# 3. prd.md
|
|
34
|
+
# 4. PRD.md
|
|
35
|
+
# 5. docs/prd.md
|
|
36
|
+
#
|
|
37
|
+
# Requirement model: a "requirement" is either a markdown checklist item
|
|
38
|
+
# (`- [ ]` / `- [x]`) or a section heading (`#`..`######`). Each requirement
|
|
39
|
+
# gets a stable id derived from its normalized text, and a content hash over
|
|
40
|
+
# the requirement line plus the body text that follows it up to the next
|
|
41
|
+
# requirement of the same-or-shallower level. This makes a CHANGED verdict
|
|
42
|
+
# fire when the prose under a heading is edited, not only when the heading text
|
|
43
|
+
# moves.
|
|
44
|
+
#
|
|
45
|
+
# Exit codes:
|
|
46
|
+
# 0 in sync (status) / lock written (lock, sync)
|
|
47
|
+
# 1 drift detected (status only)
|
|
48
|
+
# 2 usage / spec-not-found error
|
|
49
|
+
# 3 internal error (could not complete)
|
|
50
|
+
|
|
51
|
+
set -uo pipefail
|
|
52
|
+
|
|
53
|
+
SPEC_EXIT_OK=0
|
|
54
|
+
SPEC_EXIT_DRIFT=1
|
|
55
|
+
SPEC_EXIT_USAGE=2
|
|
56
|
+
SPEC_EXIT_ERROR=3
|
|
57
|
+
SPEC_SCHEMA_VERSION="1.0"
|
|
58
|
+
|
|
59
|
+
SPEC_DIR_DEFAULT=".loki/spec"
|
|
60
|
+
SPEC_LOCK_NAME="spec.lock"
|
|
61
|
+
SPEC_DRIFT_REPORT_NAME="drift-report.json"
|
|
62
|
+
|
|
63
|
+
_spec_log() { printf '[spec] %s\n' "$*" >&2; }
|
|
64
|
+
_spec_err() { printf '[spec][error] %s\n' "$*" >&2; }
|
|
65
|
+
|
|
66
|
+
# Resolve tool version from the VERSION file shipped alongside the repo.
|
|
67
|
+
_spec_tool_version() {
|
|
68
|
+
local here
|
|
69
|
+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
70
|
+
if [ -f "$here/../VERSION" ]; then
|
|
71
|
+
tr -d '[:space:]' <"$here/../VERSION"
|
|
72
|
+
else
|
|
73
|
+
echo "unknown"
|
|
74
|
+
fi
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Resolve the spec source path. Echoes the path on success, empty on failure.
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
spec_resolve_source() {
|
|
81
|
+
local explicit="${1:-}"
|
|
82
|
+
if [ -n "$explicit" ]; then
|
|
83
|
+
if [ -f "$explicit" ]; then
|
|
84
|
+
printf '%s\n' "$explicit"
|
|
85
|
+
return 0
|
|
86
|
+
fi
|
|
87
|
+
return 1
|
|
88
|
+
fi
|
|
89
|
+
local candidate
|
|
90
|
+
for candidate in \
|
|
91
|
+
".loki/generated-prd.md" \
|
|
92
|
+
"prd.md" \
|
|
93
|
+
"PRD.md" \
|
|
94
|
+
"docs/prd.md"; do
|
|
95
|
+
if [ -f "$candidate" ]; then
|
|
96
|
+
printf '%s\n' "$candidate"
|
|
97
|
+
return 0
|
|
98
|
+
fi
|
|
99
|
+
done
|
|
100
|
+
return 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Parse a spec into a requirement map (id, kind, level, text, content_hash)
|
|
105
|
+
# and print it as a compact JSON object: { "requirements": [ ... ] }.
|
|
106
|
+
#
|
|
107
|
+
# Deterministic, pure-Python (no LLM). Used by both lock and status.
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
spec_parse_requirements_json() {
|
|
110
|
+
local spec_path="$1"
|
|
111
|
+
_SPEC_PARSE_PATH="$spec_path" python3 - <<'PYEOF'
|
|
112
|
+
import hashlib, json, os, re, sys
|
|
113
|
+
|
|
114
|
+
path = os.environ["_SPEC_PARSE_PATH"]
|
|
115
|
+
try:
|
|
116
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
117
|
+
raw = fh.read()
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
sys.stderr.write("spec parse: cannot read %s: %s\n" % (path, exc))
|
|
120
|
+
sys.exit(3)
|
|
121
|
+
|
|
122
|
+
lines = raw.splitlines()
|
|
123
|
+
|
|
124
|
+
heading_re = re.compile(r'^(#{1,6})\s+(.*\S)\s*$')
|
|
125
|
+
# Checklist item: optional leading whitespace, a bullet, then [ ] / [x] / [X].
|
|
126
|
+
checklist_re = re.compile(r'^\s*[-*]\s+\[([ xX])\]\s+(.*\S)\s*$')
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def norm(text):
|
|
130
|
+
# Normalize for the id: lowercase, collapse whitespace, drop trailing
|
|
131
|
+
# punctuation. Keeps the id stable across cosmetic edits to spacing.
|
|
132
|
+
t = text.strip().lower()
|
|
133
|
+
t = re.sub(r'\s+', ' ', t)
|
|
134
|
+
t = re.sub(r'[\s:.;,]+$', '', t)
|
|
135
|
+
return t
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# First pass: locate every requirement (heading or checklist item) with its
|
|
139
|
+
# line index and a "level". Headings use their markdown level (1..6).
|
|
140
|
+
# Checklist items use level 100 (deeper than any heading) so a heading's body
|
|
141
|
+
# extends across the checklist items beneath it but each checklist item still
|
|
142
|
+
# hashes its own line.
|
|
143
|
+
reqs = []
|
|
144
|
+
for i, line in enumerate(lines):
|
|
145
|
+
m = heading_re.match(line)
|
|
146
|
+
if m:
|
|
147
|
+
level = len(m.group(1))
|
|
148
|
+
text = m.group(2).strip()
|
|
149
|
+
reqs.append({"line": i, "level": level, "kind": "heading", "text": text})
|
|
150
|
+
continue
|
|
151
|
+
c = checklist_re.match(line)
|
|
152
|
+
if c:
|
|
153
|
+
text = c.group(2).strip()
|
|
154
|
+
reqs.append({"line": i, "level": 100, "kind": "checklist", "text": text})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Second pass: compute the content hash for each requirement. The body of a
|
|
158
|
+
# requirement runs from its own line up to (but not including) the next
|
|
159
|
+
# requirement whose level is the same or shallower. For checklist items
|
|
160
|
+
# (level 100) the body is just the item line itself, because the next
|
|
161
|
+
# requirement (another checklist item at 100, or any heading at <=6) ends it
|
|
162
|
+
# immediately.
|
|
163
|
+
out = []
|
|
164
|
+
seen_ids = {}
|
|
165
|
+
n = len(reqs)
|
|
166
|
+
for idx, r in enumerate(reqs):
|
|
167
|
+
start = r["line"]
|
|
168
|
+
end = len(lines)
|
|
169
|
+
for j in range(idx + 1, n):
|
|
170
|
+
if reqs[j]["level"] <= r["level"]:
|
|
171
|
+
end = reqs[j]["line"]
|
|
172
|
+
break
|
|
173
|
+
body = "\n".join(lines[start:end])
|
|
174
|
+
h = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
175
|
+
|
|
176
|
+
base_id = norm(r["text"]) or ("req-%d" % start)
|
|
177
|
+
# Disambiguate identical requirement text by appending an occurrence index.
|
|
178
|
+
if base_id in seen_ids:
|
|
179
|
+
seen_ids[base_id] += 1
|
|
180
|
+
rid = "%s#%d" % (base_id, seen_ids[base_id])
|
|
181
|
+
else:
|
|
182
|
+
seen_ids[base_id] = 0
|
|
183
|
+
rid = base_id
|
|
184
|
+
|
|
185
|
+
out.append({
|
|
186
|
+
"id": rid,
|
|
187
|
+
"kind": r["kind"],
|
|
188
|
+
"level": r["level"],
|
|
189
|
+
"text": r["text"],
|
|
190
|
+
"line": start + 1,
|
|
191
|
+
"content_hash": h,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
json.dump({"requirements": out}, sys.stdout, indent=2)
|
|
195
|
+
sys.stdout.write("\n")
|
|
196
|
+
PYEOF
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# spec lock / spec sync core.
|
|
201
|
+
#
|
|
202
|
+
# Builds .loki/spec/spec.lock with: schema, tool version, spec path, locked-at
|
|
203
|
+
# timestamp, repo HEAD at lock time, and the parsed requirement map.
|
|
204
|
+
# `sync` is `lock` with a flag recorded in the lock so the artifact carries the
|
|
205
|
+
# human-review intent (Panel B: sync is an explicit human action).
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
spec_do_lock() {
|
|
208
|
+
local spec_path="$1"
|
|
209
|
+
local out_dir="$2"
|
|
210
|
+
local origin="$3" # "lock" or "sync"
|
|
211
|
+
|
|
212
|
+
local req_json
|
|
213
|
+
if ! req_json="$(spec_parse_requirements_json "$spec_path")"; then
|
|
214
|
+
_spec_err "failed to parse spec at $spec_path"
|
|
215
|
+
return $SPEC_EXIT_ERROR
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
# Resolve the current commit SHA honestly. Note: `git rev-parse HEAD` prints
|
|
219
|
+
# the literal string "HEAD" (and exits 0) in a repo with no commits, so the
|
|
220
|
+
# naive `|| echo "(none)"` fallback never fires there. Use `--verify HEAD`,
|
|
221
|
+
# which fails (rc!=0) when HEAD does not resolve, and record an honest
|
|
222
|
+
# sentinel instead of the failed-resolution artifact.
|
|
223
|
+
local head_sha="(none)"
|
|
224
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
225
|
+
if head_sha="$(git rev-parse --verify HEAD 2>/dev/null)"; then
|
|
226
|
+
:
|
|
227
|
+
else
|
|
228
|
+
head_sha="no-commits"
|
|
229
|
+
fi
|
|
230
|
+
fi
|
|
231
|
+
|
|
232
|
+
local locked_at tool_version
|
|
233
|
+
locked_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
234
|
+
tool_version="$(_spec_tool_version)"
|
|
235
|
+
|
|
236
|
+
mkdir -p "$out_dir" || { _spec_err "cannot create $out_dir"; return $SPEC_EXIT_ERROR; }
|
|
237
|
+
|
|
238
|
+
_SPEC_OUT="$out_dir/$SPEC_LOCK_NAME" \
|
|
239
|
+
_SPEC_REQ_JSON="$req_json" \
|
|
240
|
+
_SPEC_PATH="$spec_path" \
|
|
241
|
+
_SPEC_HEAD="$head_sha" \
|
|
242
|
+
_SPEC_LOCKED_AT="$locked_at" \
|
|
243
|
+
_SPEC_TOOLVER="$tool_version" \
|
|
244
|
+
_SPEC_SCHEMA="$SPEC_SCHEMA_VERSION" \
|
|
245
|
+
_SPEC_ORIGIN="$origin" \
|
|
246
|
+
python3 - <<'PYEOF'
|
|
247
|
+
import json, os, sys
|
|
248
|
+
|
|
249
|
+
req = json.loads(os.environ["_SPEC_REQ_JSON"])
|
|
250
|
+
doc = {
|
|
251
|
+
"schema_version": os.environ["_SPEC_SCHEMA"],
|
|
252
|
+
"produced_by": {
|
|
253
|
+
"tool": "loki spec",
|
|
254
|
+
"tool_version": os.environ["_SPEC_TOOLVER"],
|
|
255
|
+
"origin": os.environ["_SPEC_ORIGIN"],
|
|
256
|
+
},
|
|
257
|
+
"spec_path": os.environ["_SPEC_PATH"],
|
|
258
|
+
"locked_at": os.environ["_SPEC_LOCKED_AT"],
|
|
259
|
+
"locked_head": os.environ["_SPEC_HEAD"],
|
|
260
|
+
"requirements": req.get("requirements", []),
|
|
261
|
+
}
|
|
262
|
+
out = os.environ["_SPEC_OUT"]
|
|
263
|
+
with open(out, "w", encoding="utf-8") as fh:
|
|
264
|
+
json.dump(doc, fh, indent=2)
|
|
265
|
+
fh.write("\n")
|
|
266
|
+
print(out)
|
|
267
|
+
PYEOF
|
|
268
|
+
local rc=$?
|
|
269
|
+
if [ "$rc" -ne 0 ]; then
|
|
270
|
+
_spec_err "failed to write lock file"
|
|
271
|
+
return $SPEC_EXIT_ERROR
|
|
272
|
+
fi
|
|
273
|
+
|
|
274
|
+
local count
|
|
275
|
+
count="$(printf '%s' "$req_json" | python3 -c 'import sys,json; print(len(json.load(sys.stdin).get("requirements", [])))' 2>/dev/null || echo "?")"
|
|
276
|
+
local head_label
|
|
277
|
+
if [ "$head_sha" = "no-commits" ] || [ "$head_sha" = "(none)" ]; then
|
|
278
|
+
head_label="$head_sha"
|
|
279
|
+
else
|
|
280
|
+
head_label="HEAD ${head_sha:0:12}"
|
|
281
|
+
fi
|
|
282
|
+
_spec_log "$origin: $count requirement(s) from $spec_path at $head_label"
|
|
283
|
+
printf 'Locked %s requirement(s) -> %s\n' "$count" "$out_dir/$SPEC_LOCK_NAME"
|
|
284
|
+
return $SPEC_EXIT_OK
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# spec status core.
|
|
289
|
+
#
|
|
290
|
+
# Compares current spec hashes vs the lock; reports ADDED / REMOVED / CHANGED
|
|
291
|
+
# requirements and whether code changed since the locked HEAD (diff stat).
|
|
292
|
+
# Emits .loki/spec/drift-report.json and a human table. Exit 0 in-sync, 1 drift.
|
|
293
|
+
# The drift signal is the exit code (0 in sync, 1 drift); callers branch on $?.
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
spec_do_status() {
|
|
296
|
+
local spec_path="$1"
|
|
297
|
+
local out_dir="$2"
|
|
298
|
+
local as_json="$3" # "true" | "false"
|
|
299
|
+
|
|
300
|
+
local lock_file="$out_dir/$SPEC_LOCK_NAME"
|
|
301
|
+
if [ ! -f "$lock_file" ]; then
|
|
302
|
+
_spec_err "no spec lock found at $lock_file. Run 'loki spec lock' first."
|
|
303
|
+
return $SPEC_EXIT_USAGE
|
|
304
|
+
fi
|
|
305
|
+
|
|
306
|
+
local cur_json
|
|
307
|
+
if ! cur_json="$(spec_parse_requirements_json "$spec_path")"; then
|
|
308
|
+
_spec_err "failed to parse current spec at $spec_path"
|
|
309
|
+
return $SPEC_EXIT_ERROR
|
|
310
|
+
fi
|
|
311
|
+
|
|
312
|
+
# Compute code-changed-since-lock via git diff stat vs the locked HEAD.
|
|
313
|
+
local locked_head code_changed="unknown" diff_files=0 diff_ins=0 diff_del=0
|
|
314
|
+
locked_head="$(python3 -c 'import sys,json; print(json.load(open(sys.argv[1])).get("locked_head",""))' "$lock_file" 2>/dev/null || echo "")"
|
|
315
|
+
if [ -n "$locked_head" ] && [ "$locked_head" != "(none)" ] \
|
|
316
|
+
&& git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
|
317
|
+
&& git rev-parse --verify --quiet "$locked_head" >/dev/null 2>&1; then
|
|
318
|
+
local numstat
|
|
319
|
+
numstat="$(git diff --numstat "$locked_head" HEAD 2>/dev/null || echo "")"
|
|
320
|
+
if [ -n "$numstat" ]; then
|
|
321
|
+
diff_files="$(printf '%s\n' "$numstat" | grep -c . || echo 0)"
|
|
322
|
+
diff_ins="$(printf '%s\n' "$numstat" | awk '$1 ~ /^[0-9]+$/ {s+=$1} END {print s+0}')"
|
|
323
|
+
diff_del="$(printf '%s\n' "$numstat" | awk '$2 ~ /^[0-9]+$/ {s+=$2} END {print s+0}')"
|
|
324
|
+
code_changed="true"
|
|
325
|
+
else
|
|
326
|
+
code_changed="false"
|
|
327
|
+
fi
|
|
328
|
+
fi
|
|
329
|
+
|
|
330
|
+
mkdir -p "$out_dir" || { _spec_err "cannot create $out_dir"; return $SPEC_EXIT_ERROR; }
|
|
331
|
+
|
|
332
|
+
# Diff the requirement maps in Python; emit drift-report.json; print a table
|
|
333
|
+
# to stderr (status human output) and the drift flag to stdout.
|
|
334
|
+
local report_path="$out_dir/$SPEC_DRIFT_REPORT_NAME"
|
|
335
|
+
local result
|
|
336
|
+
result="$(
|
|
337
|
+
_SPEC_LOCK_FILE="$lock_file" \
|
|
338
|
+
_SPEC_CUR_JSON="$cur_json" \
|
|
339
|
+
_SPEC_REPORT="$report_path" \
|
|
340
|
+
_SPEC_SPEC_PATH="$spec_path" \
|
|
341
|
+
_SPEC_SCHEMA="$SPEC_SCHEMA_VERSION" \
|
|
342
|
+
_SPEC_CODE_CHANGED="$code_changed" \
|
|
343
|
+
_SPEC_DIFF_FILES="$diff_files" \
|
|
344
|
+
_SPEC_DIFF_INS="$diff_ins" \
|
|
345
|
+
_SPEC_DIFF_DEL="$diff_del" \
|
|
346
|
+
_SPEC_LOCKED_HEAD="${locked_head:-}" \
|
|
347
|
+
_SPEC_AS_JSON="$as_json" \
|
|
348
|
+
python3 - <<'PYEOF'
|
|
349
|
+
import json, os, sys
|
|
350
|
+
|
|
351
|
+
lock = json.load(open(os.environ["_SPEC_LOCK_FILE"], encoding="utf-8"))
|
|
352
|
+
cur = json.loads(os.environ["_SPEC_CUR_JSON"])
|
|
353
|
+
|
|
354
|
+
locked = {r["id"]: r for r in lock.get("requirements", [])}
|
|
355
|
+
current = {r["id"]: r for r in cur.get("requirements", [])}
|
|
356
|
+
|
|
357
|
+
added, removed, changed = [], [], []
|
|
358
|
+
for rid, r in current.items():
|
|
359
|
+
if rid not in locked:
|
|
360
|
+
added.append(r)
|
|
361
|
+
elif r["content_hash"] != locked[rid]["content_hash"]:
|
|
362
|
+
changed.append({"id": rid, "text": r["text"], "kind": r["kind"],
|
|
363
|
+
"line": r.get("line"),
|
|
364
|
+
"locked_hash": locked[rid]["content_hash"],
|
|
365
|
+
"current_hash": r["content_hash"]})
|
|
366
|
+
for rid, r in locked.items():
|
|
367
|
+
if rid not in current:
|
|
368
|
+
removed.append(r)
|
|
369
|
+
|
|
370
|
+
code_changed = os.environ["_SPEC_CODE_CHANGED"]
|
|
371
|
+
drift = bool(added or removed or changed)
|
|
372
|
+
|
|
373
|
+
report = {
|
|
374
|
+
"schema_version": os.environ["_SPEC_SCHEMA"],
|
|
375
|
+
"spec_path": os.environ["_SPEC_SPEC_PATH"],
|
|
376
|
+
"lock_path": os.environ["_SPEC_LOCK_FILE"],
|
|
377
|
+
"locked_head": os.environ["_SPEC_LOCKED_HEAD"],
|
|
378
|
+
"in_sync": (not drift),
|
|
379
|
+
"drift": drift,
|
|
380
|
+
"code_changed_since_lock": (code_changed == "true"),
|
|
381
|
+
"code_diff_stats": {
|
|
382
|
+
"files_changed": int(os.environ["_SPEC_DIFF_FILES"]),
|
|
383
|
+
"insertions": int(os.environ["_SPEC_DIFF_INS"]),
|
|
384
|
+
"deletions": int(os.environ["_SPEC_DIFF_DEL"]),
|
|
385
|
+
},
|
|
386
|
+
"summary": {
|
|
387
|
+
"added": len(added),
|
|
388
|
+
"removed": len(removed),
|
|
389
|
+
"changed": len(changed),
|
|
390
|
+
},
|
|
391
|
+
"added": added,
|
|
392
|
+
"removed": removed,
|
|
393
|
+
"changed": changed,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
with open(os.environ["_SPEC_REPORT"], "w", encoding="utf-8") as fh:
|
|
397
|
+
json.dump(report, fh, indent=2)
|
|
398
|
+
fh.write("\n")
|
|
399
|
+
|
|
400
|
+
# Human table -> stderr (stdout is reserved for the drift flag the caller reads).
|
|
401
|
+
def line(s=""):
|
|
402
|
+
sys.stderr.write(s + "\n")
|
|
403
|
+
|
|
404
|
+
line("")
|
|
405
|
+
line("Spec drift status")
|
|
406
|
+
line(" spec: %s" % report["spec_path"])
|
|
407
|
+
line(" lock: %s" % report["lock_path"])
|
|
408
|
+
line(" locked HEAD: %s" % (report["locked_head"] or "(none)"))
|
|
409
|
+
cc = report["code_diff_stats"]
|
|
410
|
+
if report["code_changed_since_lock"]:
|
|
411
|
+
line(" code: CHANGED since lock (%d files, +%d / -%d)" %
|
|
412
|
+
(cc["files_changed"], cc["insertions"], cc["deletions"]))
|
|
413
|
+
elif code_changed == "false":
|
|
414
|
+
line(" code: unchanged since lock")
|
|
415
|
+
else:
|
|
416
|
+
line(" code: (could not compare against locked HEAD)")
|
|
417
|
+
line("")
|
|
418
|
+
line(" ADDED: %d" % len(added))
|
|
419
|
+
line(" REMOVED: %d" % len(removed))
|
|
420
|
+
line(" CHANGED: %d" % len(changed))
|
|
421
|
+
line("")
|
|
422
|
+
if drift:
|
|
423
|
+
for r in added:
|
|
424
|
+
line(" + ADDED [%s] %s" % (r["kind"], r["text"]))
|
|
425
|
+
for r in removed:
|
|
426
|
+
line(" - REMOVED [%s] %s" % (r["kind"], r["text"]))
|
|
427
|
+
for r in changed:
|
|
428
|
+
line(" ~ CHANGED [%s] %s" % (r["kind"], r["text"]))
|
|
429
|
+
line("")
|
|
430
|
+
line(" Verdict: SPEC-DRIFTED. Review, then run 'loki spec sync' to re-lock.")
|
|
431
|
+
else:
|
|
432
|
+
line(" Verdict: SPEC-TRUE. Spec and lock agree.")
|
|
433
|
+
line("")
|
|
434
|
+
|
|
435
|
+
if os.environ["_SPEC_AS_JSON"] == "true":
|
|
436
|
+
# Machine output on stdout when --json requested: the full report plus a
|
|
437
|
+
# trailing DRIFT line the bash caller parses for the exit code.
|
|
438
|
+
sys.stdout.write(json.dumps(report, indent=2) + "\n")
|
|
439
|
+
|
|
440
|
+
# Always emit the drift flag as the final stdout line for the bash caller.
|
|
441
|
+
sys.stdout.write("DRIFT=%s\n" % ("true" if drift else "false"))
|
|
442
|
+
PYEOF
|
|
443
|
+
)"
|
|
444
|
+
local rc=$?
|
|
445
|
+
if [ "$rc" -ne 0 ]; then
|
|
446
|
+
_spec_err "failed to compute drift report"
|
|
447
|
+
return $SPEC_EXIT_ERROR
|
|
448
|
+
fi
|
|
449
|
+
|
|
450
|
+
# Emit the JSON body (everything except the trailing DRIFT= line) to stdout
|
|
451
|
+
# when --json was requested, then read the drift flag.
|
|
452
|
+
local drift_flag
|
|
453
|
+
drift_flag="$(printf '%s\n' "$result" | grep '^DRIFT=' | tail -1 | cut -d= -f2)"
|
|
454
|
+
if [ "$as_json" = "true" ]; then
|
|
455
|
+
printf '%s\n' "$result" | grep -v '^DRIFT='
|
|
456
|
+
fi
|
|
457
|
+
|
|
458
|
+
printf 'Drift report: %s\n' "$report_path" >&2
|
|
459
|
+
|
|
460
|
+
if [ "$drift_flag" = "true" ]; then
|
|
461
|
+
return $SPEC_EXIT_DRIFT
|
|
462
|
+
fi
|
|
463
|
+
return $SPEC_EXIT_OK
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
# ---------------------------------------------------------------------------
|
|
467
|
+
# Verify integration hook.
|
|
468
|
+
#
|
|
469
|
+
# Called by autonomy/verify.sh when .loki/spec/spec.lock exists. Runs the
|
|
470
|
+
# drift check quietly and, on drift, emits ONE SPEC_DRIFT record to stdout in
|
|
471
|
+
# the verify finding TSV shape:
|
|
472
|
+
# severity \t category \t source \t file \t line \t message
|
|
473
|
+
# Severity is Medium (-> CONCERNS, per the task). Graceful no-op (prints
|
|
474
|
+
# nothing, returns 0) when there is no lock or the spec cannot be resolved.
|
|
475
|
+
#
|
|
476
|
+
# This function is intentionally side-effect-light for the verify caller: it
|
|
477
|
+
# still writes the drift-report.json (a useful artifact) but never blocks and
|
|
478
|
+
# never prints to the verify human channel.
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
spec_verify_hook() {
|
|
481
|
+
local out_dir="${1:-$SPEC_DIR_DEFAULT}"
|
|
482
|
+
local lock_file="$out_dir/$SPEC_LOCK_NAME"
|
|
483
|
+
[ -f "$lock_file" ] || return 0
|
|
484
|
+
|
|
485
|
+
local spec_path
|
|
486
|
+
# Prefer the spec path recorded in the lock; fall back to resolution.
|
|
487
|
+
spec_path="$(python3 -c 'import sys,json; print(json.load(open(sys.argv[1])).get("spec_path",""))' "$lock_file" 2>/dev/null || echo "")"
|
|
488
|
+
# MEDIUM-4: the lock recorded a spec path but that file is now MISSING (the
|
|
489
|
+
# locked spec was deleted). That is real drift -- the contract the lock binds
|
|
490
|
+
# no longer exists -- so emit a Medium spec_drift finding instead of silently
|
|
491
|
+
# returning 0. NEVER fall back to spec_resolve_source here: comparing against
|
|
492
|
+
# a different candidate file would mask the deletion and attest a spec that is
|
|
493
|
+
# not the locked one. The empty-spec_path case below is a SEPARATE, legitimate
|
|
494
|
+
# fallback (legacy locks that never recorded a path).
|
|
495
|
+
if [ -n "$spec_path" ] && [ ! -f "$spec_path" ]; then
|
|
496
|
+
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
497
|
+
"Medium" "spec_drift" "deterministic:loki-spec" "$spec_path" "null" \
|
|
498
|
+
"locked spec file missing: $spec_path (the spec is the contract; restore it or run 'loki spec sync' after review to re-lock against the current spec)"
|
|
499
|
+
return 0
|
|
500
|
+
fi
|
|
501
|
+
if [ -z "$spec_path" ]; then
|
|
502
|
+
spec_path="$(spec_resolve_source "")" || return 0
|
|
503
|
+
fi
|
|
504
|
+
[ -n "$spec_path" ] && [ -f "$spec_path" ] || return 0
|
|
505
|
+
|
|
506
|
+
# Run status quietly (suppress its human table on stderr). The drift report
|
|
507
|
+
# JSON it writes is what we read below; its exit code is intentionally
|
|
508
|
+
# ignored here (we attest, we do not block, in the hook).
|
|
509
|
+
spec_do_status "$spec_path" "$out_dir" "false" >/dev/null 2>&1 || true
|
|
510
|
+
|
|
511
|
+
local report_path="$out_dir/$SPEC_DRIFT_REPORT_NAME"
|
|
512
|
+
[ -f "$report_path" ] || return 0
|
|
513
|
+
|
|
514
|
+
local summary
|
|
515
|
+
summary="$(python3 - "$report_path" <<'PYEOF' 2>/dev/null || echo ""
|
|
516
|
+
import json, sys
|
|
517
|
+
r = json.load(open(sys.argv[1]))
|
|
518
|
+
if not r.get("drift"):
|
|
519
|
+
sys.exit(0)
|
|
520
|
+
s = r.get("summary", {})
|
|
521
|
+
print("Spec has drifted from its lock: %d added, %d removed, %d changed requirement(s). The spec is the contract; run 'loki spec status' for detail and 'loki spec sync' to re-lock after review." % (s.get("added", 0), s.get("removed", 0), s.get("changed", 0)))
|
|
522
|
+
PYEOF
|
|
523
|
+
)"
|
|
524
|
+
[ -n "$summary" ] || return 0
|
|
525
|
+
|
|
526
|
+
# Emit one Medium SPEC_DRIFT finding in the verify TSV shape.
|
|
527
|
+
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
528
|
+
"Medium" "spec_drift" "deterministic:loki-spec" "$spec_path" "null" "$summary"
|
|
529
|
+
return 0
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
# Help
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
spec_help() {
|
|
536
|
+
cat <<'EOF'
|
|
537
|
+
loki spec - the living spec: the spec is the contract; we keep it true.
|
|
538
|
+
|
|
539
|
+
USAGE:
|
|
540
|
+
loki spec <lock|status|sync> [<spec-path>] [options]
|
|
541
|
+
|
|
542
|
+
DESCRIPTION:
|
|
543
|
+
Binds a spec (PRD) to content hashes so drift between the spec and the code
|
|
544
|
+
is detectable cheaply and deterministically -- no LLM pass required to ask
|
|
545
|
+
"has the spec gone stale". The lock + drift report are auditable trust
|
|
546
|
+
artifacts that feed `loki verify`.
|
|
547
|
+
|
|
548
|
+
SUBCOMMANDS:
|
|
549
|
+
lock Build .loki/spec/spec.lock: a deterministic map of spec
|
|
550
|
+
requirements (checklist items and headings) to content hashes,
|
|
551
|
+
plus repo HEAD at lock time.
|
|
552
|
+
status Cheap drift detection: compare current spec hashes vs the lock,
|
|
553
|
+
report ADDED / REMOVED / CHANGED requirements and whether code
|
|
554
|
+
changed since the locked HEAD. Emits .loki/spec/drift-report.json
|
|
555
|
+
and a human table. Exit 0 in-sync, 1 on drift.
|
|
556
|
+
sync Refresh the lock after a human review (explicit action). This MVP
|
|
557
|
+
NEVER auto-rewrites the spec itself.
|
|
558
|
+
|
|
559
|
+
SPEC RESOLUTION (when <spec-path> is omitted, first match wins):
|
|
560
|
+
.loki/generated-prd.md -> prd.md -> PRD.md -> docs/prd.md
|
|
561
|
+
|
|
562
|
+
OPTIONS:
|
|
563
|
+
--out <dir> Output directory for the lock + report. Default: .loki/spec
|
|
564
|
+
--json (status only) Emit the full drift report JSON to stdout.
|
|
565
|
+
-h, --help Show this help.
|
|
566
|
+
|
|
567
|
+
EXIT CODES:
|
|
568
|
+
0 in sync (status) / lock written (lock, sync)
|
|
569
|
+
1 drift detected (status)
|
|
570
|
+
2 usage error (spec or lock not found)
|
|
571
|
+
3 internal error
|
|
572
|
+
|
|
573
|
+
VERIFY INTEGRATION:
|
|
574
|
+
When .loki/spec/spec.lock exists, `loki verify` runs the drift check and
|
|
575
|
+
adds a Medium-severity SPEC_DRIFT finding on drift, which maps to a CONCERNS
|
|
576
|
+
verdict. No lock = graceful no-op.
|
|
577
|
+
|
|
578
|
+
EOF
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
# Entry point
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
spec_main() {
|
|
585
|
+
local sub="${1:-}"
|
|
586
|
+
[ $# -gt 0 ] && shift
|
|
587
|
+
|
|
588
|
+
case "$sub" in
|
|
589
|
+
-h|--help|help|"") spec_help; return $SPEC_EXIT_OK ;;
|
|
590
|
+
esac
|
|
591
|
+
|
|
592
|
+
local spec_arg=""
|
|
593
|
+
local out_dir="$SPEC_DIR_DEFAULT"
|
|
594
|
+
local as_json="false"
|
|
595
|
+
|
|
596
|
+
while [ $# -gt 0 ]; do
|
|
597
|
+
case "$1" in
|
|
598
|
+
-h|--help) spec_help; return $SPEC_EXIT_OK ;;
|
|
599
|
+
--out) out_dir="${2:-}"; shift 2 ;;
|
|
600
|
+
--json) as_json="true"; shift ;;
|
|
601
|
+
--) shift; break ;;
|
|
602
|
+
-*) _spec_err "unknown option: $1"; spec_help; return $SPEC_EXIT_USAGE ;;
|
|
603
|
+
*)
|
|
604
|
+
if [ -z "$spec_arg" ]; then spec_arg="$1"; else
|
|
605
|
+
_spec_err "unexpected argument: $1"; return $SPEC_EXIT_USAGE
|
|
606
|
+
fi
|
|
607
|
+
shift ;;
|
|
608
|
+
esac
|
|
609
|
+
done
|
|
610
|
+
|
|
611
|
+
local spec_path
|
|
612
|
+
if ! spec_path="$(spec_resolve_source "$spec_arg")"; then
|
|
613
|
+
if [ -n "$spec_arg" ]; then
|
|
614
|
+
_spec_err "spec file not found: $spec_arg"
|
|
615
|
+
else
|
|
616
|
+
_spec_err "no spec found (looked for .loki/generated-prd.md, prd.md, PRD.md, docs/prd.md). Pass a path explicitly."
|
|
617
|
+
fi
|
|
618
|
+
return $SPEC_EXIT_USAGE
|
|
619
|
+
fi
|
|
620
|
+
|
|
621
|
+
case "$sub" in
|
|
622
|
+
lock)
|
|
623
|
+
spec_do_lock "$spec_path" "$out_dir" "lock"
|
|
624
|
+
return $?
|
|
625
|
+
;;
|
|
626
|
+
sync)
|
|
627
|
+
spec_do_lock "$spec_path" "$out_dir" "sync"
|
|
628
|
+
return $?
|
|
629
|
+
;;
|
|
630
|
+
status)
|
|
631
|
+
spec_do_status "$spec_path" "$out_dir" "$as_json"
|
|
632
|
+
return $?
|
|
633
|
+
;;
|
|
634
|
+
*)
|
|
635
|
+
_spec_err "unknown subcommand: $sub"
|
|
636
|
+
spec_help
|
|
637
|
+
return $SPEC_EXIT_USAGE
|
|
638
|
+
;;
|
|
639
|
+
esac
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
# Allow direct execution: bash autonomy/spec.sh <sub> [args]
|
|
643
|
+
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
|
644
|
+
spec_main "$@"
|
|
645
|
+
exit $?
|
|
646
|
+
fi
|