loki-mode 9.16.0 → 9.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md 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 v9.16.0
6
+ # Loki Mode v9.17.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
470
470
 
471
471
  ---
472
472
 
473
- **v9.16.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.17.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.16.0
1
+ 9.17.0
@@ -382,23 +382,26 @@ labels_str = ', '.join(labels) if labels else ''
382
382
 
383
383
  _ac_text = (title + ' ' + body).lower()
384
384
  _ac_rules = [
385
- (['save', 'persist', 'store', 'databas', 'crud'],
385
+ (['save', 'persist', 'store', 'databas', 'crud'], 'PERSIST',
386
386
  'Data the change writes survives a restart (a real store, not in-memory state).'),
387
- (['auth', 'login', 'sign in', 'session', 'permission'],
387
+ (['auth', 'login', 'sign in', 'session', 'permission'], 'AUTH',
388
388
  'The auth path is exercised end to end including the denied case (401/403), not only the happy path.'),
389
- (['api', 'endpoint', 'rest', 'graphql', 'route'],
389
+ (['api', 'endpoint', 'rest', 'graphql', 'route'], 'API',
390
390
  'Each affected endpoint returns the documented status codes and is callable without a browser.'),
391
- (['payment', 'stripe', 'billing', 'invoice', 'subscription'],
391
+ (['payment', 'stripe', 'billing', 'invoice', 'subscription'], 'PAY',
392
392
  'The payment path runs against provider test mode; no mocked charge stands in for the integration.'),
393
- (['bug', 'fix', 'regression', 'broken', 'crash', 'error'],
393
+ (['bug', 'fix', 'regression', 'broken', 'crash', 'error'], 'REPRO',
394
394
  'A test reproduces the reported failure and FAILS before the fix, then passes after it.'),
395
- (['perf', 'slow', 'latency', 'timeout', 'memory leak'],
395
+ (['perf', 'slow', 'latency', 'timeout', 'memory leak'], 'PERF',
396
396
  'The improvement is measured before and after, and the numbers appear in the change.'),
397
- (['security', 'vulnerab', 'injection', 'xss', 'csrf'],
397
+ (['security', 'vulnerab', 'injection', 'xss', 'csrf'], 'SEC',
398
398
  'A test demonstrates the vulnerable behavior is refused after the change.'),
399
+
399
400
  ]
400
- _hits = [c for kws, c in _ac_rules if any(k in _ac_text for k in kws)]
401
- derived_ac = ('\n'.join('- ' + h for h in _hits) + '\n') if _hits else ''
401
+ # Stable, content-derived IDs: the axis comes from WHICH rule fired, so adding
402
+ # a rule never renumbers an existing criterion and a receipt can cite one.
403
+ _hits = [(ax, c) for kws, ax, c in _ac_rules if any(k in _ac_text for k in kws)]
404
+ derived_ac = ('\n'.join(f'- AC-{ax}-001: {c}' for ax, c in _hits) + '\n') if _hits else ''
402
405
 
403
406
  prd = f'''# PRD: {title}
404
407
 
@@ -186,15 +186,93 @@ def render_text(res):
186
186
  return "\n".join(out)
187
187
 
188
188
 
189
+ # What --fix writes for each missing criterion. Only files whose CORRECT content
190
+ # can be derived from the repo itself appear here.
191
+ #
192
+ # test_command and dependency_lock are deliberately absent: guessing a test
193
+ # command writes a line that lies (`npm test` in a repo with no runner exits
194
+ # non-zero forever, and the readiness check would then report "present" for
195
+ # something that does not work), and a lockfile must come from the real package
196
+ # manager or it is worse than none. Those stay REPORTED, never generated.
197
+ #
198
+ # This is the difference between a scorecard and an executable one -- Factory's
199
+ # /readiness-report -> /readiness-fix -- without the failure mode where the fix
200
+ # makes the score green while the underlying capability is still missing.
201
+ FIXABLE = {
202
+ "gitignore": (".gitignore", "node_modules/\n__pycache__/\n.env\n.venv/\ndist/\n*.log\n"),
203
+ "readme": ("README.md", None), # content derived below
204
+ "agent_brief": ("AGENTS.md", None), # content derived below
205
+ }
206
+
207
+
208
+ def _fix(root, res):
209
+ """Create the missing files whose content can be derived honestly.
210
+
211
+ Returns (written, skipped) where skipped names criteria that a generated
212
+ file could not honestly satisfy.
213
+ """
214
+ written, skipped = [], []
215
+ project = os.path.basename(os.path.abspath(root)) or "this project"
216
+ # assess() reports `missing` as a list of criterion IDs (strings), not the
217
+ # full check dicts. Tolerate both so a later shape change does not silently
218
+ # fix nothing.
219
+ for entry in res.get("missing", []):
220
+ cid = entry["id"] if isinstance(entry, dict) else entry
221
+ if cid not in FIXABLE:
222
+ skipped.append((cid, "must come from the real toolchain, not a guess"))
223
+ continue
224
+ name, body = FIXABLE[cid]
225
+ path = os.path.join(root, name)
226
+ if os.path.exists(path):
227
+ continue
228
+ if cid == "readme":
229
+ body = (f"# {project}\n\n"
230
+ "## What this is\n\n_TODO: one paragraph._\n\n"
231
+ "## Run it\n\n```sh\n# TODO: the command that starts this project\n```\n\n"
232
+ "## Test it\n\n```sh\n# TODO: the command that runs the tests\n```\n")
233
+ elif cid == "agent_brief":
234
+ body = (f"# AGENTS.md\n\nBriefing for coding agents working in {project}.\n\n"
235
+ "## Commands\n\n- Build: _TODO_\n- Test: _TODO_\n- Lint: _TODO_\n\n"
236
+ "## Conventions\n\n_TODO: what a reviewer would flag._\n\n"
237
+ "## Do not\n\n_TODO: the things that break this repo._\n")
238
+ try:
239
+ with open(path, "w") as fh:
240
+ fh.write(body)
241
+ written.append(name)
242
+ except OSError as exc:
243
+ skipped.append((cid, f"could not write {name}: {exc}"))
244
+ return written, skipped
245
+
246
+
189
247
  def main(argv):
190
248
  as_json = "--json" in argv
249
+ do_fix = "--fix" in argv
191
250
  root = "."
192
251
  for a in argv:
193
252
  if not a.startswith("-"):
194
253
  root = a
195
254
  break
196
255
  res = assess(root)
197
- print(json.dumps(res, indent=2) if as_json else render_text(res))
256
+
257
+ if do_fix and res.get("status") == "measured":
258
+ written, skipped = _fix(root, res)
259
+ res = assess(root) # re-measure: report what is true AFTER the fix
260
+ res["fix"] = {"written": written,
261
+ "skipped": [{"id": i, "reason": r} for i, r in skipped]}
262
+
263
+ if as_json:
264
+ print(json.dumps(res, indent=2))
265
+ else:
266
+ print(render_text(res))
267
+ if do_fix:
268
+ fx = res.get("fix", {})
269
+ print("")
270
+ for name in fx.get("written", []):
271
+ print(f" wrote {name} (a stub -- fill in the TODOs)")
272
+ for s in fx.get("skipped", []):
273
+ print(f" skipped {s['id']}: {s['reason']}")
274
+ if not fx.get("written") and not fx.get("skipped"):
275
+ print(" nothing to fix.")
198
276
  return 0 if res.get("status") == "measured" else 3
199
277
 
200
278
 
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env python3
2
+ """Which gates block, which only advise, and what promoting one would have cost.
3
+
4
+ Ona's Veto Exec ships an audit-first ladder: "Start with audit rules, review
5
+ matches, then promote confirmed rules to block." The load-bearing part is the
6
+ MIDDLE step. A policy you cannot safely turn on is a policy nobody turns on, so
7
+ before flipping a gate to blocking you get to see what it WOULD have blocked.
8
+
9
+ We already had both ends and nothing in between: gates are advisory or blocking,
10
+ three promotion knobs exist (LOKI_GATE_MAGIC_DEBATE_BLOCKING, LOKI_COV_ENFORCE,
11
+ LOKI_POLICY_APPROVAL_ENFORCE), and .loki/quality/gate-failure-count.json has
12
+ counted per-gate failures the whole time. Nothing joined them, so an operator
13
+ deciding whether to promote a gate had to guess.
14
+
15
+ DETERMINISTIC. Reads two files and the environment. No model, no network, no
16
+ spend -- same repo state, same answer, and every number is a count a reader can
17
+ recompute by opening the same file.
18
+
19
+ NEVER PROMOTES ANYTHING. This reports; it does not change policy. Turning a gate
20
+ blocking stays an explicit operator act via the named environment variable,
21
+ because a tool that silently starts blocking is the thing operators most
22
+ reasonably fear.
23
+
24
+ Shape (assess() and --json, schema_version 1). This is the contract the
25
+ dashboard endpoint GET /api/gate-policy and tests/test_gate_policy_endpoint.py
26
+ both read against, so field names here are load-bearing:
27
+
28
+ schema_version int 1
29
+ status str "measured"
30
+ ledger str "present" | "absent" -- whether the per-gate failure
31
+ ledger .loki/quality/gate-failure-count.json was read
32
+ gates list one record per known gate, blocking gates first, each
33
+ group sorted by name:
34
+ gate str gate name (e.g. "code_review")
35
+ mode str "blocking" | "advisory" -- for a promotable gate this
36
+ depends on the ENVIRONMENT at call time
37
+ promotable bool True when a real promotion knob exists in run.sh
38
+ audit_hits int|null failures counted for this gate. null means
39
+ UNMEASURED -- no ledger, or no entry for this gate.
40
+ NEVER 0 for an unmeasured gate: 0 is the positive claim
41
+ that the gate ran and never fired, which is the false
42
+ green an absent measurement always produces.
43
+ why str one-line description of what the gate checks
44
+ promote_with str|null "VAR=value" to make an advisory gate blocking; null
45
+ when the gate already blocks
46
+ """
47
+
48
+ import json
49
+ import os
50
+ import sys
51
+
52
+ SCHEMA_VERSION = 1
53
+
54
+ # Gates that can be promoted from advisory to blocking, and the knob that does
55
+ # it. Only gates with a REAL knob in run.sh appear here -- listing an aspiration
56
+ # would tell an operator to set a variable nothing reads.
57
+ PROMOTABLE = {
58
+ "magic_debate": ("LOKI_GATE_MAGIC_DEBATE_BLOCKING", "true",
59
+ "spec-vs-implementation debate on generated modules"),
60
+ "test_coverage": ("LOKI_COV_ENFORCE", "1",
61
+ "project test runner pass/fail"),
62
+ "policy_approval": ("LOKI_POLICY_APPROVAL_ENFORCE", "1",
63
+ "staged-autonomy approval policy"),
64
+ }
65
+
66
+ # Gates that block unconditionally. Listed so the report is a complete picture
67
+ # rather than only the promotable subset -- an operator asking "what blocks here"
68
+ # should not have to read run.sh to find out.
69
+ ALWAYS_BLOCKING = {
70
+ "static_analysis": "CodeQL, ESLint/Pylint, type-checker findings on the diff",
71
+ "code_review": "3-reviewer blind review; Critical/High = BLOCK",
72
+ "mock_integrity": "tautological-assertion and mock-ratio detection",
73
+ "mutation_integrity": "assertion-churn (test-fitting) detection",
74
+ }
75
+
76
+
77
+ def _counts(loki_dir):
78
+ """Per-gate failure counts, or {} when the ledger has not been written."""
79
+ path = os.path.join(loki_dir, "quality", "gate-failure-count.json")
80
+ try:
81
+ with open(path) as fh:
82
+ data = json.load(fh)
83
+ return data if isinstance(data, dict) else {}
84
+ except (OSError, ValueError):
85
+ return {}
86
+
87
+
88
+ def assess(loki_dir=".loki", env=None):
89
+ env = os.environ if env is None else env
90
+ counts = _counts(loki_dir)
91
+ have_ledger = bool(counts)
92
+
93
+ gates = []
94
+ for name, why in sorted(ALWAYS_BLOCKING.items()):
95
+ gates.append({
96
+ "gate": name, "mode": "blocking", "promotable": False,
97
+ "audit_hits": counts.get(name) if have_ledger else None,
98
+ "why": why, "promote_with": None,
99
+ })
100
+ for name, (var, val, why) in sorted(PROMOTABLE.items()):
101
+ on = str(env.get(var, "")).lower() in ("1", "true", "yes")
102
+ gates.append({
103
+ "gate": name,
104
+ "mode": "blocking" if on else "advisory",
105
+ "promotable": True,
106
+ # None, not 0: an absent ledger means UNMEASURED, and reporting 0
107
+ # would read as "this gate never fired" -- the same false green a
108
+ # missing measurement always produces.
109
+ "audit_hits": counts.get(name) if have_ledger else None,
110
+ "why": why,
111
+ "promote_with": None if on else f"{var}={val}",
112
+ })
113
+
114
+ return {
115
+ "schema_version": SCHEMA_VERSION,
116
+ "status": "measured",
117
+ "ledger": "present" if have_ledger else "absent",
118
+ "gates": gates,
119
+ }
120
+
121
+
122
+ def render_text(res):
123
+ out = ["Gate policy -- what blocks here, and what only advises", ""]
124
+ for g in res["gates"]:
125
+ hits = g["audit_hits"]
126
+ if hits is None:
127
+ hit_s = "not measured"
128
+ elif hits == 0:
129
+ hit_s = "0 hits"
130
+ else:
131
+ hit_s = f"{hits} hit{'s' if hits != 1 else ''}"
132
+ mode = g["mode"].upper()
133
+ out.append(f" {mode:9} {g['gate']:20} {hit_s}")
134
+ out.append(f" {g['why']}")
135
+ if g["promote_with"]:
136
+ verb = "would have blocked" if (hits or 0) > 0 else "has not fired"
137
+ out.append(f" advisory: {verb} -- promote with {g['promote_with']}")
138
+ out.append("")
139
+
140
+ if res["ledger"] == "absent":
141
+ out.append(" No gate ledger yet (.loki/quality/gate-failure-count.json).")
142
+ out.append(" Counts read 'not measured' rather than 0: an absent")
143
+ out.append(" measurement is not evidence a gate never fired.")
144
+ else:
145
+ out.append(" Hits come from .loki/quality/gate-failure-count.json.")
146
+ out.append(" Open it and count the same numbers yourself.")
147
+ out.append("")
148
+ out.append(" This command never promotes a gate. Promotion is an explicit")
149
+ out.append(" operator act via the variable named above.")
150
+ return "\n".join(out)
151
+
152
+
153
+ def main(argv):
154
+ as_json = "--json" in argv
155
+ loki_dir = ".loki"
156
+ for a in argv:
157
+ if not a.startswith("-"):
158
+ loki_dir = a
159
+ break
160
+ res = assess(loki_dir)
161
+ print(json.dumps(res, indent=2) if as_json else render_text(res))
162
+ return 0
163
+
164
+
165
+ if __name__ == "__main__":
166
+ sys.exit(main(sys.argv[1:]))
@@ -93,6 +93,92 @@ ANCHOR_REASONS = {
93
93
  "diff_range_mismatch": "the base..head diff does not match the receipt's file set",
94
94
  }
95
95
 
96
+ # HISTORICAL vs LIVE: the distinction that stops an old receipt from reading as
97
+ # a regression.
98
+ #
99
+ # `ANCHORED 0 of 9` is alarming until you know WHY. Two very different things
100
+ # produce it, and a flat count of reasons cannot tell them apart:
101
+ #
102
+ # FROZEN The receipt file itself lacks the anchor. base_sha was written as
103
+ # "" and that JSON is on disk forever. No future fix can anchor it,
104
+ # and rewriting a receipt to make a metric look better is the exact
105
+ # dishonesty this module exists to refuse. These are HISTORY.
106
+ # LIVE The receipt records real shas; only the ENVIRONMENT cannot resolve
107
+ # them right now -- a shallow clone, an unmerged branch, or a file
108
+ # set that still has uncommitted edits. These can anchor later, with
109
+ # no change to the receipt.
110
+ #
111
+ # Measured on this repo: all 9 receipts are frozen (8 base_sha_empty, 1
112
+ # greenfield_no_baseline) and were generated 2026-07-27 and 2026-07-31, BEFORE
113
+ # proof-generator learned to read .loki/state/start-sha (commit 99ce689d,
114
+ # 2026-08-07). So the zero is fully explained by history.
115
+ # Frozen because the GENERATOR failed to record something it should have. These
116
+ # are the only two where recency is meaningful: before the fix they are history,
117
+ # after it they are a live bug in proof-generator.
118
+ FROZEN_GENERATOR_REASONS = frozenset({
119
+ "head_sha_empty",
120
+ "base_sha_empty",
121
+ })
122
+
123
+ # Frozen BY DESIGN, and correct at any date. A genuinely greenfield repo has no
124
+ # earlier commit to diff against, and a receipt written over uncommitted work
125
+ # honestly has base == head. Neither can ever anchor, and neither is a defect --
126
+ # so recency must NOT be applied to them. Calling a correct greenfield receipt a
127
+ # regression would be a false alarm on the signal added to prevent false alarms.
128
+ FROZEN_BY_DESIGN_REASONS = frozenset({
129
+ "greenfield_no_baseline",
130
+ "change_not_committed",
131
+ })
132
+
133
+ FROZEN_REASONS = FROZEN_GENERATOR_REASONS | FROZEN_BY_DESIGN_REASONS
134
+
135
+ # The two sets must stay disjoint. If a reason appeared in both, classification
136
+ # would depend on which branch is checked first -- and a by-design case that
137
+ # drifted into the generator set would start alarming as a regression the moment
138
+ # someone reordered the checks. Assert the invariant rather than trusting order.
139
+ assert not (FROZEN_GENERATOR_REASONS & FROZEN_BY_DESIGN_REASONS), \
140
+ "a reason cannot be both a generator failure and correct by design"
141
+
142
+ # The commit that taught proof-generator to resolve base_sha from
143
+ # .loki/state/start-sha when the env var is absent. A receipt generated at or
144
+ # after this instant should carry a real baseline, so a FROZEN reason on one is
145
+ # NOT history -- it is a live regression in the generator.
146
+ #
147
+ # Recency is the discriminator because it is the only one the receipts actually
148
+ # support: they carry generated_at (verified on all 9), and loki_version tracks
149
+ # releases rather than this fix. A frozen-vs-live split ALONE would file a newly
150
+ # broken receipt under history, which is the green-wash this guards against.
151
+ BASE_SHA_FIX_UTC = "2026-08-07T13:39:32Z" # 99ce689d, committed 09:39:32 -04:00
152
+
153
+
154
+ def classify_unanchored(reason, generated_at):
155
+ """Bucket an unanchored receipt: historical, regression, or live.
156
+
157
+ Returns one of:
158
+ "by_design" -- unanchorable and CORRECT: a greenfield run with no earlier
159
+ commit, or a receipt over uncommitted work. Never a defect,
160
+ at any date, so recency is not applied.
161
+ "historical" -- the generator failed to record an anchor, in a receipt
162
+ written BEFORE the fix. History, and never anchorable now.
163
+ "regression" -- the same generator failure AFTER the fix. The anchor is
164
+ being dropped again. This is the only bucket that alarms.
165
+ "live" -- the receipt is fine; the environment cannot resolve it yet.
166
+ """
167
+ if reason in FROZEN_BY_DESIGN_REASONS:
168
+ return "by_design"
169
+ if reason not in FROZEN_GENERATOR_REASONS:
170
+ return "live"
171
+ # No timestamp means we cannot place it relative to the fix. Refuse to call
172
+ # it history, because that is the direction that hides a regression.
173
+ #
174
+ # ponytail: lexicographic compare, correct only for the "...Z" ISO form the
175
+ # generator writes (verified on all 9 receipts). An offset-form timestamp
176
+ # would sort below the constant and read as historical -- the hiding
177
+ # direction. Parse properly only if a generator ever emits offsets.
178
+ if not generated_at:
179
+ return "regression"
180
+ return "historical" if generated_at < BASE_SHA_FIX_UTC else "regression"
181
+
96
182
 
97
183
  def _git(args, cwd, timeout=30):
98
184
  """Run a git command read-only. Returns (rc, stdout). Never raises.
@@ -315,6 +401,9 @@ def outcome_for_receipt(proof_path, cwd):
315
401
  state, reason = resolve_anchor(base_sha, head_sha, files, cwd)
316
402
  rec["anchor"] = {"state": state, "reason": reason}
317
403
  if state != "anchored":
404
+ # An old receipt is not a regression. Classify so a reader can tell a
405
+ # frozen pre-fix receipt from a generator that started dropping anchors.
406
+ rec["anchor"]["klass"] = classify_unanchored(reason, rec["generated_at"])
318
407
  rec["outcome"] = UNKNOWN
319
408
  rec["reason"] = ANCHOR_REASONS.get(reason, reason or "not anchored")
320
409
  rec["commands"] = [
@@ -381,11 +470,15 @@ def summarize(records):
381
470
  # as "no data" instead of "your receipts are not recording a landed sha",
382
471
  # which is an actionable defect.
383
472
  reasons = {}
473
+ klasses = {"by_design": 0, "historical": 0, "regression": 0, "live": 0}
384
474
  for r in records:
385
475
  a = r.get("anchor") or {}
386
476
  if a.get("state") and a["state"] != "anchored":
387
477
  key = a.get("reason") or "unknown"
388
478
  reasons[key] = reasons.get(key, 0) + 1
479
+ k = a.get("klass")
480
+ if k in klasses:
481
+ klasses[k] += 1
389
482
 
390
483
  summary = {
391
484
  "receipts_total": total,
@@ -393,6 +486,12 @@ def summarize(records):
393
486
  "receipts_unknown": len(unknown),
394
487
  "reverted": len(reverted),
395
488
  "unanchored_reasons": reasons,
489
+ # The count that turns an alarming zero into an explained one. A
490
+ # regression here is the only bucket that warrants action.
491
+ "unanchored_by_design": klasses["by_design"],
492
+ "unanchored_historical": klasses["historical"],
493
+ "unanchored_regression": klasses["regression"],
494
+ "unanchored_live": klasses["live"],
396
495
  }
397
496
  if measured:
398
497
  summary["change_failure_rate"] = round(len(reverted) / len(measured), 4)
@@ -442,6 +541,29 @@ def render_text(records, summary, note=None):
442
541
  out.append(" Why not anchored (a receipt must prove base..head IS the change):")
443
542
  for k, n in sorted(reasons.items(), key=lambda kv: -kv[1]):
444
543
  out.append(f" {k:24} {n:3} {ANCHOR_REASONS.get(k, '')}")
544
+
545
+ # Without this, ANCHORED 0 of N reads as a regression when it is history.
546
+ hist = summary.get("unanchored_historical", 0)
547
+ regr = summary.get("unanchored_regression", 0)
548
+ live = summary.get("unanchored_live", 0)
549
+ design = summary.get("unanchored_by_design", 0)
550
+ if hist or regr or live or design:
551
+ out.append("")
552
+ if design:
553
+ out.append(f" {design} by design: a greenfield run or uncommitted"
554
+ f" work has no baseline to diff against. Correct, not a"
555
+ f" defect, and never anchorable.")
556
+ if hist:
557
+ out.append(f" {hist} historical: written before the base_sha fix"
558
+ f" ({BASE_SHA_FIX_UTC[:10]}); the receipt itself has no"
559
+ f" baseline, so these can never anchor. Not a defect.")
560
+ if live:
561
+ out.append(f" {live} live: the receipt records real shas; this"
562
+ f" clone cannot resolve them yet. May anchor later.")
563
+ if regr:
564
+ out.append(f" {regr} REGRESSION: written AFTER the fix and still"
565
+ f" missing a baseline. The generator is dropping the"
566
+ f" anchor -- this one is a defect.")
445
567
  out.append("")
446
568
  cfr = summary.get("change_failure_rate")
447
569
  if cfr == UNKNOWN:
@@ -943,12 +943,46 @@ def _git_diffstat(target_dir, include_diffs):
943
943
 
944
944
  Order of preference:
945
945
  1. _LOKI_RUN_START_SHA -- the run's own baseline (run.sh exports it).
946
- 2. The empty tree -- correct for a GREENFIELD run (a repo with no commits
946
+ 2. .loki/state/start-sha -- the SAME baseline, persisted to disk. The env
947
+ var is only exported inside run_autonomous (run.sh:21690), so a receipt
948
+ generated outside that scope -- `loki proof` run by hand, a resumed
949
+ run, a receipt written after the runner exited -- saw an empty env var
950
+ and fell straight through to the empty tree.
951
+ 3. The empty tree -- correct for a GREENFIELD run (a repo with no commits
947
952
  at start), where "everything that now exists" IS the run's output and
948
953
  there is no earlier commit to diff against.
949
- 3. Empty string -- let workspace_diff apply its own fallbacks.
954
+ 4. Empty string -- let workspace_diff apply its own fallbacks.
955
+
956
+ WHY STEP 2 EXISTS. Without it every receipt on this repo recorded
957
+ base_sha="" -- measured: 9 of 9 receipts in .loki/proofs/, and the dashboard
958
+ correctly reported "9 receipts, 0 verified". An empty base is unanchorable,
959
+ so outcome_ledger.resolve_anchor() returns unanchored/base_sha_empty and NO
960
+ receipt can be verified. The persisted file already existed and both other
961
+ consumers already read it (run.sh:7779, completion-council.sh:1834); this
962
+ reader was the only one that did not, so it silently lost the anchor.
963
+
964
+ The greenfield fallback is deliberately kept BELOW the file: falling back to
965
+ the empty tree when a real baseline exists would attest to "everything in
966
+ the repo" as this run's output, which is a much worse lie than an empty
967
+ base_sha. Order matters more than the addition.
950
968
  """
951
969
  base = os.environ.get("_LOKI_RUN_START_SHA", "").strip()
970
+ if not base:
971
+ # Same baseline, read from disk. Mirrors run.sh:7779 and
972
+ # completion-council.sh:1834 rather than inventing a third convention.
973
+ try:
974
+ with open(os.path.join(target_dir, ".loki", "state", "start-sha")) as fh:
975
+ _persisted = fh.read().strip()
976
+ # Only trust it if it names a commit that actually exists HERE. A
977
+ # stale or foreign SHA would produce a diff against nothing and an
978
+ # integrity hash nobody can recompute.
979
+ if _persisted and subprocess.run(
980
+ ["git", "cat-file", "-e", _persisted + "^{commit}"],
981
+ cwd=target_dir, capture_output=True, timeout=10,
982
+ ).returncode == 0:
983
+ base = _persisted
984
+ except (OSError, subprocess.SubprocessError, ValueError):
985
+ pass
952
986
  if not base:
953
987
  # Greenfield: no baseline commit existed when the run started.
954
988
  base = _empty_tree_sha(target_dir)
@@ -990,8 +1024,33 @@ def _collect_iterations(loki_dir):
990
1024
  return {"count": count, "succeeded": n_completed, "failed": n_failed}
991
1025
 
992
1026
 
1027
+ # Acceptance-criterion ids as minted at intake ("- AC-<AXIS>-NNN: <text>", see
1028
+ # _brief_acceptance_criteria in autonomy/loki). Anchored and strict on purpose:
1029
+ # a loose pattern would count prose that merely mentions an id, and the whole
1030
+ # value of the id is that a citation points at exactly one criterion.
1031
+ _AC_ID_RE = re.compile(r"^- (AC-[A-Z]+-[0-9]{3}): ", re.MULTILINE)
1032
+
1033
+
1034
+ def _spec_criteria_declared(text):
1035
+ """Return the acceptance-criterion ids the spec DECLARES, in spec order.
1036
+
1037
+ DECLARED, NOT SATISFIED. This records which criteria exist in the spec and
1038
+ nothing more -- no check runs here, and no field in the receipt asserts that
1039
+ any of these was met. Naming it criteria_met would be a lie we cannot back.
1040
+
1041
+ Empty list when the spec declares none (an older PRD, a hand-written spec,
1042
+ or a run with no spec file at all). Never invented, never a placeholder.
1043
+ """
1044
+ if not text:
1045
+ return []
1046
+ # Deduped: an id is a citation target, so each must resolve to one criterion.
1047
+ # A repeated id is a spec bug; counting it twice would not make it citable.
1048
+ return list(dict.fromkeys(_AC_ID_RE.findall(text)))
1049
+
1050
+
993
1051
  def _collect_spec(loki_dir, target_dir):
994
- """Return spec dict {source, brief}. brief truncated to 600 chars."""
1052
+ """Return spec dict {source, brief, criteria_declared}. brief truncated to
1053
+ 600 chars."""
995
1054
  prd_path = os.environ.get("PRD_PATH", "").strip()
996
1055
  source = ""
997
1056
  brief = ""
@@ -1018,7 +1077,15 @@ def _collect_spec(loki_dir, target_dir):
1018
1077
  # Full brief here; the <=600 cap is applied AFTER redaction in generate()
1019
1078
  # so a secret straddling the cap cannot be sliced into an under-length
1020
1079
  # fragment that bypasses the redactor.
1021
- return {"source": source, "brief": brief}
1080
+ #
1081
+ # Criteria are parsed from the FULL spec text, not from the 600-char display
1082
+ # cap: a PRD's acceptance-criteria block sits well past char 600, so parsing
1083
+ # the capped brief would silently drop most of them.
1084
+ return {
1085
+ "source": source,
1086
+ "brief": brief,
1087
+ "criteria_declared": _spec_criteria_declared(brief),
1088
+ }
1022
1089
 
1023
1090
 
1024
1091
  def _self_version():