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.
@@ -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