loki-mode 8.9.0 → 8.10.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 v8.9.0
6
+ # Loki Mode v8.10.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -469,4 +469,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
469
469
 
470
470
  ---
471
471
 
472
- **v8.9.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
472
+ **v8.10.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 8.9.0
1
+ 8.10.0
@@ -163,12 +163,103 @@ def _render(s):
163
163
  return "\n".join(lines)
164
164
 
165
165
 
166
+ def _fmt_secs(ms):
167
+ return f"{ms / 1000.0:.0f}s"
168
+
169
+
170
+ WINDOW = 3 # last N iterations rendered; the block ships in EVERY prompt
171
+
172
+
173
+ def prompt_block(loki_dir):
174
+ """Render the run's own efficiency trend for injection into the next prompt.
175
+
176
+ WHY THIS EXISTS. The engine has written .loki/metrics/efficiency/iteration-N.json
177
+ every iteration for its entire life and never once read it back into a decision.
178
+ Cost, duration and cache data flowed OUT to a budget breaker and an offline
179
+ report, never IN to the agent producing the cost. The agent was the only party
180
+ to the run with no visibility into its own efficiency.
181
+
182
+ THE INCENTIVE TRAP, AND THE GUARD. A block reporting cost and duration ALONE
183
+ instructs the model to be cheap, and the cheapest iteration is the one that
184
+ does less work and verifies less. That is gate-weakening through the front
185
+ door, with no gate edited. So spend is NEVER rendered without the progress /
186
+ rework split beside it: the actionable number is "you failed N iterations and
187
+ repeated them", whose correct response is "stop failing gates", not "spend
188
+ less". Enforced by test, not by convention.
189
+
190
+ Returns "" when there are no usable records, so an absent or empty metrics
191
+ dir adds NOTHING to the prompt (no dangling header). This is why the block is
192
+ rendered here rather than from attribute(), whose notes list is non-empty even
193
+ on an empty dir.
194
+ """
195
+ recs = _iteration_records(loki_dir)
196
+ if not recs:
197
+ return ""
198
+
199
+ s = attribute(loki_dir)
200
+ lines = []
201
+
202
+ # Per-iteration tail: the shape of the trend (getting slower? pricier?) is
203
+ # what a model can actually steer on, and it is invisible in an aggregate.
204
+ # Windowed to the last few so the block stays a few lines in EVERY prompt.
205
+ tail = recs[-WINDOW:]
206
+ for r in tail:
207
+ it = r.get("iteration", "?")
208
+ parts = []
209
+ dur = r.get("duration_ms")
210
+ if isinstance(dur, (int, float)) and dur >= 0:
211
+ parts.append(_fmt_secs(dur))
212
+ cost = r.get("cost_usd")
213
+ if isinstance(cost, (int, float)):
214
+ parts.append(f"${float(cost):.2f}")
215
+ cread = r.get("cache_read_tokens")
216
+ inp = r.get("input_tokens")
217
+ if isinstance(cread, (int, float)) and isinstance(inp, (int, float)):
218
+ denom = float(inp) + float(cread)
219
+ if denom > 0:
220
+ parts.append(f"cache {cread / denom:.0%}")
221
+ st = str(r.get("status", "")).strip().lower()
222
+ if st:
223
+ parts.append(st)
224
+ lines.append(f" iter {it}: " + ", ".join(parts) if parts else f" iter {it}")
225
+
226
+ if not lines:
227
+ return ""
228
+
229
+ header = "EFFICIENCY TREND (your own last %d iteration(s); steer on it):" % len(tail)
230
+ out = [header] + lines
231
+
232
+ # The anti-incentive guard: spend never ships without the outcome split.
233
+ prog = s["progress"]["count"]
234
+ rew = s["rework"]["count"]
235
+ total = s["iterations"]
236
+ out.append(f" progress {prog}/{total}, rework {rew}/{total}")
237
+ share = s.get("rework_cost_share")
238
+ if rew and share is not None:
239
+ out.append(
240
+ f" {rew} iteration(s) failed and were repeated, costing {share:.0%} of the run. "
241
+ "Cut rework by fixing what the gate flagged, NEVER by verifying less."
242
+ )
243
+ return "\n".join(out)
244
+
245
+
166
246
  def main():
167
247
  ap = argparse.ArgumentParser(description=__doc__)
168
248
  ap.add_argument("--loki-dir", default=".loki")
169
249
  ap.add_argument("--json", action="store_true")
250
+ ap.add_argument(
251
+ "--prompt-block",
252
+ action="store_true",
253
+ help="render the compact trend block for prompt injection (empty when no records)",
254
+ )
170
255
  args = ap.parse_args()
171
256
 
257
+ if args.prompt_block:
258
+ block = prompt_block(args.loki_dir)
259
+ if block:
260
+ print(block)
261
+ return 0
262
+
172
263
  s = attribute(args.loki_dir)
173
264
  if args.json:
174
265
  print(json.dumps(s, indent=2))
package/autonomy/loki CHANGED
@@ -26798,6 +26798,9 @@ ig = r.get("_ignored_executable_fields") or []
26798
26798
  if ig:
26799
26799
  print(f" NOTE: ignored executable-looking fields (never run): {', '.join(ig)}")
26800
26800
  print("Stored in .loki/agents/installed.json -- visible to 'loki agent list/info/run'.")
26801
+ print("It also joins the code-review reviewer pool: when its focus keywords match")
26802
+ print("a diff it is dispatched as a real reviewer (appended to the built-in battery,")
26803
+ print("never replacing one). See skills/quality-gates.md 'Adding Your Own Reviewer'.")
26801
26804
  PYEOF
26802
26805
  else
26803
26806
  echo -e "${RED}Install failed:${NC} $result"
package/autonomy/run.sh CHANGED
@@ -4154,6 +4154,135 @@ except Exception:
4154
4154
  print('')" "$_fp_file" 2>/dev/null)"
4155
4155
  fi
4156
4156
 
4157
+ # Where the time went, per stage. Same "written but never read" story as
4158
+ # first-preview above: emit_stage_complete (run.sh:2413) has appended a
4159
+ # stage_complete record -- stage, status, duration_s, iteration -- to
4160
+ # events.jsonl since v7.91.x, and NOTHING consumed it. A founder watching a
4161
+ # 322-word issue take 25+ minutes inside iteration 1 had no way to see which
4162
+ # step ate it, because the measurement existed and was never surfaced.
4163
+ #
4164
+ # Read-only aggregation over a file the run already wrote: no new subprocess
4165
+ # per stage, no new writer, one python3 pass at terminal time. We deliberately
4166
+ # render the AGENT remainder (wall clock minus summed stages) rather than
4167
+ # stages alone. The 9 emit_stage_complete sites are all post-iteration gates,
4168
+ # which sum to seconds; a table of only those would print "gates: 90s" on a
4169
+ # 25-minute run and still not answer the question. The remainder is the
4170
+ # provider/agent time, and it is usually the answer.
4171
+ #
4172
+ # Best-effort and honest about absence: no events file, no stage records, or
4173
+ # unparseable lines render NOTHING rather than a fabricated zero -- same
4174
+ # reasoning as first_preview_s. A wrong timing table is worse than silence.
4175
+ local stage_timing=""
4176
+ local _ev_file="$loki_dir/events.jsonl"
4177
+ if [ -f "$_ev_file" ]; then
4178
+ stage_timing="$(LOKI_RUN_START_EPOCH="${_LOKI_RUN_START_EPOCH:-}" python3 -c "
4179
+ import json, os, sys
4180
+ tot = {}
4181
+ order = []
4182
+ first = last = None
4183
+
4184
+ # events.jsonl is NEVER truncated between runs (no rm/rotate anywhere in the
4185
+ # tree), so a second 'loki start' in the same workspace would otherwise sum
4186
+ # stages from every previous run against THIS run's wall clock -- inflating
4187
+ # staged past wall and silently killing the total/remainder rows. Filter to
4188
+ # records at or after this run's start. The ISO timestamp is already on every
4189
+ # record, so this costs nothing extra.
4190
+ run_start_iso = None
4191
+ _es = (os.environ.get('LOKI_RUN_START_EPOCH') or '').strip()
4192
+ if _es:
4193
+ try:
4194
+ from datetime import datetime, timezone
4195
+ run_start_iso = datetime.fromtimestamp(float(_es), timezone.utc)
4196
+ except Exception:
4197
+ run_start_iso = None
4198
+
4199
+ def _in_run(ts):
4200
+ if run_start_iso is None or not isinstance(ts, str) or not ts:
4201
+ return True # no reliable boundary: keep (old behavior)
4202
+ try:
4203
+ from datetime import datetime
4204
+ t = datetime.fromisoformat(ts.replace('Z', '+00:00'))
4205
+ return t >= run_start_iso
4206
+ except Exception:
4207
+ return True
4208
+ try:
4209
+ with open(sys.argv[1], errors='replace') as fh:
4210
+ for line in fh:
4211
+ line = line.strip()
4212
+ if not line:
4213
+ continue
4214
+ try:
4215
+ rec = json.loads(line)
4216
+ except Exception:
4217
+ continue # malformed line: skip, never abort the summary
4218
+ if not isinstance(rec, dict):
4219
+ continue
4220
+ ts = rec.get('timestamp')
4221
+ if not _in_run(ts):
4222
+ continue # record belongs to an earlier run in this workspace
4223
+ if isinstance(ts, str) and ts:
4224
+ if first is None:
4225
+ first = ts
4226
+ last = ts
4227
+ if rec.get('type') != 'stage_complete':
4228
+ continue
4229
+ d = rec.get('data') or {}
4230
+ if not isinstance(d, dict):
4231
+ continue
4232
+ name = d.get('stage')
4233
+ dur = d.get('duration_s')
4234
+ if not name or not isinstance(dur, (int, float)) or dur < 0:
4235
+ continue
4236
+ if name not in tot:
4237
+ tot[name] = 0.0
4238
+ order.append(name)
4239
+ tot[name] += float(dur)
4240
+ except Exception:
4241
+ sys.exit(0)
4242
+ if not tot:
4243
+ sys.exit(0)
4244
+
4245
+ def human(s):
4246
+ s = int(round(s))
4247
+ return '%dm %02ds' % (s // 60, s % 60) if s >= 60 else '%ds' % s
4248
+
4249
+ # Wall clock: prefer the run-start epoch the runner exported; else derive from
4250
+ # the first/last event timestamps. Absent both, we print stages with no total
4251
+ # rather than inventing a denominator.
4252
+ wall = None
4253
+ env_start = os.environ.get('LOKI_RUN_START_EPOCH') or ''
4254
+ try:
4255
+ if env_start.strip():
4256
+ import time
4257
+ wall = time.time() - float(env_start)
4258
+ except Exception:
4259
+ wall = None
4260
+ if wall is None and first and last:
4261
+ try:
4262
+ from datetime import datetime
4263
+ f = datetime.fromisoformat(first.replace('Z', '+00:00'))
4264
+ l = datetime.fromisoformat(last.replace('Z', '+00:00'))
4265
+ wall = (l - f).total_seconds()
4266
+ except Exception:
4267
+ wall = None
4268
+
4269
+ staged = sum(tot.values())
4270
+ out = []
4271
+ for name in sorted(order, key=lambda n: -tot[n]):
4272
+ out.append(' %-22s %s' % (name.replace('_', ' '), human(tot[name])))
4273
+ if wall is not None and wall >= staged:
4274
+ rem = wall - staged
4275
+ # The bucket that answers 'where did the 25 minutes go'.
4276
+ # NOT labeled 'agent': the provider call is itself a bracketed stage above,
4277
+ # so this remainder is everything else (checklist verification, app runner,
4278
+ # playwright, council, memory). Calling it 'agent' would print two different
4279
+ # measurements under one name.
4280
+ out.append(' %-22s %s' % ('other (unaccounted)', human(rem)))
4281
+ out.append(' %-22s %s' % ('total', human(wall)))
4282
+ print('\n'.join(out))
4283
+ " "$_ev_file" 2>/dev/null)"
4284
+ fi
4285
+
4157
4286
  # Branch + diff stats vs the run-start SHA (best-effort; non-git or empty
4158
4287
  # baseline yields empty values, which we render as "unknown"/"0").
4159
4288
  local start_sha="${_LOKI_RUN_START_SHA:-}"
@@ -4299,6 +4428,11 @@ except Exception:
4299
4428
  fi
4300
4429
  printf '%-14s %s\n' "Tasks:" "pending=$pending in_progress=$in_progress completed=$completed failed=$failed"
4301
4430
  echo ""
4431
+ if [ -n "$stage_timing" ]; then
4432
+ echo "Where the time went:"
4433
+ echo "$stage_timing"
4434
+ echo ""
4435
+ fi
4302
4436
  if [ -n "$evidence_inconclusive_line" ]; then
4303
4437
  echo "$evidence_inconclusive_line"
4304
4438
  echo ""
@@ -13821,6 +13955,42 @@ if types_file and os.path.exists(types_file):
13821
13955
  except Exception:
13822
13956
  pass # Fall back to hardcoded specialists
13823
13957
 
13958
+ # R10 extension seam: agents installed by the user via `loki agent install`
13959
+ # (.loki/agents/installed.json) join the reviewer pool. Built-ins above are
13960
+ # gated on a hardcoded FOCUS_KEYWORDS allowlist, which no user-chosen type can
13961
+ # ever match, so an installed agent was silently dropped and its persona never
13962
+ # reached a reviewer. Keywords come from the manifest's own `focus` list, which
13963
+ # hub_install.py already validates as <= 200-char strings.
13964
+ # Data only: hub_install.py never executes anything from a manifest.
13965
+ # Kept in a SEPARATE dict, never merged into SPECIALISTS: entering the built-in
13966
+ # pool would let a user agent win a `ranked[:want]` slot and DISPLACE a built-in
13967
+ # reviewer (observed displacing security-sentinel before this was split out),
13968
+ # and would also flip the all-zero defaults path.
13969
+ INSTALLED_SPECIALISTS = {}
13970
+ try:
13971
+ import importlib.util as _ilu
13972
+ _hub_path = os.path.join(os.path.dirname(os.path.abspath(types_file)), "hub_install.py")
13973
+ _spec = _ilu.spec_from_file_location("loki_hub_install", _hub_path)
13974
+ _hub = _ilu.module_from_spec(_spec)
13975
+ _spec.loader.exec_module(_hub)
13976
+ for _inst in _hub.installed_agent_list():
13977
+ _t = _inst.get("type", "")
13978
+ # Never let an installed agent shadow a built-in reviewer perspective.
13979
+ if not _t or _t in SPECIALISTS:
13980
+ continue
13981
+ _kw = [str(k).strip().lower() for k in _inst.get("focus", []) if str(k).strip()]
13982
+ if not _kw:
13983
+ continue # No keywords means it could never score; skip rather than always-on.
13984
+ INSTALLED_SPECIALISTS[_t] = {
13985
+ "keywords": _kw,
13986
+ "focus": _inst.get("capabilities", "") or _inst.get("name", _t),
13987
+ "checks": "Review from " + _inst.get("name", _t) + " perspective: " + ", ".join(_inst.get("focus", [])),
13988
+ "priority": 100 + len(INSTALLED_SPECIALISTS),
13989
+ "persona": _inst.get("persona", ""),
13990
+ }
13991
+ except Exception:
13992
+ pass # Corrupt or absent installed.json must never break code review.
13993
+
13824
13994
  diff_path = os.environ.get("LOKI_REVIEW_DIFF_FILE", "")
13825
13995
  files_path = os.environ.get("LOKI_REVIEW_FILES_FILE", "")
13826
13996
 
@@ -13880,6 +14050,23 @@ if all(s == 0 for s in scores.values()):
13880
14050
  else:
13881
14051
  selected = ranked[:want]
13882
14052
 
14053
+ # User-installed agents are APPENDED, never allowed to compete for the `want`
14054
+ # built-in slots -- same discipline as the dependency-analyst append below, so
14055
+ # installing an agent can only ADD scrutiny, never remove a built-in reviewer.
14056
+ # Only those whose keywords actually matched this diff fire, so an installed
14057
+ # a11y auditor stays silent on a backend-only change.
14058
+ # ponytail: hard cap of 2, no env var. Each appended agent costs one more LLM
14059
+ # reviewer call every iteration. Raise the constant if that ceiling bites.
14060
+ _MAX_INSTALLED_REVIEWERS = 2
14061
+ installed_selected = []
14062
+ for _n, _spec in INSTALLED_SPECIALISTS.items():
14063
+ scores[_n] = sum(1 for kw in _spec["keywords"] if kw in search_text)
14064
+ for _n in sorted(INSTALLED_SPECIALISTS, key=lambda n: (-scores[n], INSTALLED_SPECIALISTS[n]["priority"])):
14065
+ if len(installed_selected) >= _MAX_INSTALLED_REVIEWERS:
14066
+ break
14067
+ if scores[_n] > 0:
14068
+ installed_selected.append(_n)
14069
+
13883
14070
  # A changed JavaScript manifest or lockfile always receives the specialist that
13884
14071
  # understands the compact Git/npm metadata. Append rather than replace so a
13885
14072
  # dependency change never removes another keyword-selected review perspective.
@@ -13929,6 +14116,13 @@ reviewers = mandatory + [
13929
14116
  "checks": SPECIALISTS[name]["checks"]
13930
14117
  }
13931
14118
  for name in selected
14119
+ ] + [
14120
+ {
14121
+ "name": name,
14122
+ "focus": INSTALLED_SPECIALISTS[name]["focus"],
14123
+ "checks": INSTALLED_SPECIALISTS[name]["checks"]
14124
+ }
14125
+ for name in installed_selected
13932
14126
  ]
13933
14127
  if os.environ.get("LOKI_REVIEW_REQUIREMENTS_ONLY") == "1":
13934
14128
  reviewers = [
@@ -13939,7 +14133,7 @@ if os.environ.get("LOKI_REVIEW_REQUIREMENTS_ONLY") == "1":
13939
14133
  result = {
13940
14134
  "reviewers": reviewers,
13941
14135
  "scores": {n: scores[n] for n in scores},
13942
- "pool_size": len(SPECIALISTS)
14136
+ "pool_size": len(SPECIALISTS) + len(installed_selected)
13943
14137
  }
13944
14138
  print(json.dumps(result))
13945
14139
  SPECIALIST_SELECT
@@ -16412,6 +16606,9 @@ pricing = {
16412
16606
  'sonnet': {'input': 3.00, 'output': 15.00},
16413
16607
  'haiku': {'input': 1.00, 'output': 5.00},
16414
16608
  'gpt-5.3-codex': {'input': 1.75, 'output': 14.00},
16609
+ 'gpt-5.6-sol': {'input': 2.50, 'output': 20.00},
16610
+ 'gpt-5.6-terra': {'input': 1.50, 'output': 12.00},
16611
+ 'gpt-5.6-luna': {'input': 0.50, 'output': 4.00},
16415
16612
  }
16416
16613
  for f in glob.glob('${efficiency_dir}/*.json'):
16417
16614
  try:
@@ -16424,7 +16621,20 @@ for f in glob.glob('${efficiency_dir}/*.json'):
16424
16621
  p = pricing.get(model, pricing['sonnet'])
16425
16622
  inp = d.get('input_tokens', 0)
16426
16623
  out = d.get('output_tokens', 0)
16624
+ # Cache tiers. The writer has emitted these since v6.82.0 and they
16625
+ # DOMINATE real traffic: a measured iteration carried 797,496
16626
+ # cache-read tokens against 10,272 of plain input. Pricing them at
16627
+ # zero under-counted a real iteration 5.4x, so a breaker set to
16628
+ # stop a runaway let it run far past the cap. Published multipliers:
16629
+ # cache read 0.1x input, cache write 1.25x input.
16630
+ #
16631
+ # This mirrors the TS route's calculateCostFromRecords
16632
+ # (loki-ts/src/runner/budget.ts). Both routes must agree or the
16633
+ # same run reports two different spends.
16634
+ cr = d.get('cache_read_tokens', 0) or 0
16635
+ cw = d.get('cache_creation_tokens', 0) or 0
16427
16636
  total += (inp / 1_000_000) * p['input'] + (out / 1_000_000) * p['output']
16637
+ total += (cr / 1_000_000) * (p['input'] * 0.1) + (cw / 1_000_000) * (p['input'] * 1.25)
16428
16638
  except: pass
16429
16639
  print(round(total, 4))
16430
16640
  " 2>/dev/null || echo "0")
@@ -18827,6 +19037,31 @@ if d.get('blocked'):
18827
19037
  memory_context_section="CONTEXT: $context_injection"
18828
19038
  fi
18829
19039
 
19040
+ # Efficiency trend injection -- close the eval feedback loop.
19041
+ # .loki/metrics/efficiency/iteration-N.json has been written every iteration
19042
+ # for the engine's entire life and read back only by a stop-only budget
19043
+ # breaker and an offline report, never by the agent producing the cost.
19044
+ #
19045
+ # SINGLE RENDERER: the text comes from iteration_attribution.py --prompt-block,
19046
+ # the exact same entry point the Bun route calls (build_prompt.ts
19047
+ # buildEfficiencyTrend), so the two routes are byte-identical by construction
19048
+ # rather than by two renderers kept in sync forever.
19049
+ #
19050
+ # Emits "" on absent/empty metrics, so an unmeasured run adds NOTHING.
19051
+ # Opt out with LOKI_EVAL_TREND=0.
19052
+ # Accepts BOTH "0" and "false" (case-insensitive): this repo uses both
19053
+ # toggle conventions, and honouring only one makes the other a silent no-op.
19054
+ # Byte-mirrored in build_prompt.ts buildEfficiencyTrend().
19055
+ local _eval_trend_optout
19056
+ _eval_trend_optout="$(printf '%s' "${LOKI_EVAL_TREND:-1}" | tr '[:upper:]' '[:lower:]')"
19057
+ local efficiency_trend=""
19058
+ if [ "$_eval_trend_optout" != "0" ] && [ "$_eval_trend_optout" != "false" ] \
19059
+ && [ -r "${SCRIPT_DIR}/lib/iteration_attribution.py" ] \
19060
+ && [ -d ".loki" ]; then
19061
+ efficiency_trend="$(python3 "${SCRIPT_DIR}/lib/iteration_attribution.py" \
19062
+ --loki-dir ".loki" --prompt-block 2>/dev/null || true)"
19063
+ fi
19064
+
18830
19065
  # PRD Checklist status injection (v5.44.0)
18831
19066
  local checklist_status=""
18832
19067
  if [ -n "$prd" ] && [ ! -f ".loki/checklist/checklist.json" ]; then
@@ -19194,6 +19429,10 @@ except Exception:
19194
19429
  [ -n "$app_runner_info" ] && printf '%s\n' "$app_runner_info"
19195
19430
  [ -n "$playwright_info" ] && printf '%s\n' "$playwright_info"
19196
19431
  [ -n "$memory_context_section" ] && printf '%s\n' "$memory_context_section"
19432
+ # Volatile per-iteration data: belongs below [CACHE_BREAKPOINT], never in the
19433
+ # cache-stable prefix. Same ordinal position as the Bun route (after the
19434
+ # context section, before the completion instruction).
19435
+ [ -n "$efficiency_trend" ] && printf '%s\n' "$efficiency_trend"
19197
19436
  printf '%s\n' "$completion_instruction"
19198
19437
  printf '</dynamic_context>\n'
19199
19438
  }
@@ -21857,6 +22096,11 @@ if __name__ == "__main__":
21857
22096
 
21858
22097
  log_info "${PROVIDER_DISPLAY_NAME:-Claude} exited with code $exit_code after ${duration}s"
21859
22098
 
22099
+ # The provider call is the largest single bucket in any iteration and was
22100
+ # the one the founder could not see. start_time already exists, so this
22101
+ # costs zero extra subprocesses -- we pass the existing epoch through.
22102
+ emit_stage_complete "agent" "$([ "$exit_code" -eq 0 ] 2>/dev/null && echo pass || echo fail)" "$start_time"
22103
+
21860
22104
  # v7.5.12 Gap A: Distinguish signal-induced exits (130/143/137) from clean failure.
21861
22105
  # Without this, post-iteration logic may quietly proceed past a SIGINT/SIGTERM,
21862
22106
  # leaving stale state and confusing the next iteration. Any non-zero exit is a
@@ -22403,8 +22647,14 @@ if __name__ == "__main__":
22403
22647
  # Auto-generate docs (default-on) BEFORE the staleness check and the
22404
22648
  # gate, so neither nags the user to run 'loki docs generate' by hand.
22405
22649
  # Opt out with LOKI_AUTO_DOCS=false.
22650
+ # Bracketed because this is the single biggest non-provider step in
22651
+ # the loop (the doc suite has cost ~25min on a real build) and it was
22652
+ # the one nobody could see. Reuses the existing helper: one date call,
22653
+ # no new subprocess per stage.
22406
22654
  if [ "$ITERATION_COUNT" -gt 0 ] && ! loki_is_supervised_simple_web; then
22407
- auto_generate_docs_if_needed
22655
+ local _docgen_t0=$(date +%s 2>/dev/null); local _docgen_ok=pass
22656
+ auto_generate_docs_if_needed || _docgen_ok=fail
22657
+ emit_stage_complete "doc_generation" "$_docgen_ok" "$_docgen_t0"
22408
22658
  fi
22409
22659
  # Documentation staleness check (v6.75.0)
22410
22660
  if [ "$ITERATION_COUNT" -gt 0 ] && ! loki_is_supervised_simple_web; then
@@ -22580,9 +22830,23 @@ if __name__ == "__main__":
22580
22830
  run_memory_consolidation
22581
22831
  # No on_run_complete: a force-stop must never open a "done" PR.
22582
22832
  emit_completion_summary force_stopped
22583
- save_state $retry "force_stopped" 0
22833
+ # Exit 20, not 0. Every other signal here already says this
22834
+ # run is NOT verified-complete -- the header, the warning,
22835
+ # the refusal to open a PR -- but the exit code said the
22836
+ # opposite, and the exit code is the only one a CI job, a
22837
+ # Kubernetes Job, or a shell `&&` actually reads. A
22838
+ # stagnation force-stop was therefore indistinguishable
22839
+ # from success to every automated caller.
22840
+ #
22841
+ # 20 is the established "deterministic terminal failure"
22842
+ # code, already used by max_iterations_reached
22843
+ # (run.sh:20796, :20877) for the same class of outcome:
22844
+ # the run stopped without verifying the work. Retrying is
22845
+ # pointless; a human needs to look. Two terminals with the
22846
+ # same meaning must not report opposite exit codes.
22847
+ save_state $retry "force_stopped" 20
22584
22848
  rm -f "$iter_output" 2>/dev/null
22585
- return 0
22849
+ return 20
22586
22850
  fi
22587
22851
  echo ""
22588
22852
  if loki_is_supervised_simple_web; then
@@ -24610,8 +24874,14 @@ except Exception:
24610
24874
  _final_state_file="$(_loki_state_file)"
24611
24875
  _final_status=$(LOKI_STATE_FILE="$_final_state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status','unknown'))" 2>/dev/null || echo "unknown")
24612
24876
  case "$_final_status" in
24613
- council_approved|council_force_approved|deterministic_gates_passed|completion_promise_fulfilled|force_stopped|paused|interrupted|stopped)
24877
+ council_approved|council_force_approved|deterministic_gates_passed|completion_promise_fulfilled|paused|interrupted|stopped)
24614
24878
  result=0 ;;
24879
+ # force_stopped belongs HERE too, for the same reason. A council
24880
+ # force-stop (stagnation, or a flood of done-signals) means the run
24881
+ # gave up WITHOUT verifying the work -- the code already says so in
24882
+ # its header, its warning, and its refusal to open a PR. Reporting
24883
+ # it as a clean stop made it indistinguishable from success to the
24884
+ # only consumer that matters to automation: the exit code.
24615
24885
  # budget_exceeded belongs HERE, not with the human-controlled stops.
24616
24886
  # It sat in the result=0 arm on the rationale that "a human will
24617
24887
  # resume", which is true of `paused` (a human pressed pause) and
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "8.9.0"
10
+ __version__ = "8.10.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try: