loki-mode 11.10.1 → 11.15.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.
Files changed (99) hide show
  1. package/README.md +2 -2
  2. package/SKILL.md +3 -3
  3. package/VERSION +1 -1
  4. package/autonomy/lib/backlog.py +18 -3
  5. package/autonomy/lib/chat.sh +6 -0
  6. package/autonomy/lib/engine10-push.sh +26 -2
  7. package/autonomy/lib/proof-generator.py +16 -0
  8. package/autonomy/lib/trust_trajectory.py +130 -0
  9. package/autonomy/lib/workspace.py +2 -3
  10. package/autonomy/loki +263 -58
  11. package/autonomy/quickstart.sh +40 -0
  12. package/autonomy/run.sh +39 -15
  13. package/autonomy/trigger-server.py +452 -29
  14. package/bin/loki +4 -1
  15. package/completions/_loki +1 -1
  16. package/completions/loki.bash +16 -1
  17. package/dashboard/__init__.py +1 -1
  18. package/docs/CLI-REFERENCE.md +7 -4
  19. package/docs/DEPLOYMENT.md +12 -5
  20. package/docs/EVALUATING.md +13 -0
  21. package/docs/INSTALLATION.md +2 -2
  22. package/docs/SIGNED-RECEIPTS.md +7 -0
  23. package/docs/WORKSPACES.md +2 -2
  24. package/docs/cost-controls.md +26 -0
  25. package/docs/merge-queue.md +19 -0
  26. package/docs/sentry-intake.md +4 -0
  27. package/docs/slack.md +2 -1
  28. package/docs/trackers.md +12 -1
  29. package/docs/v10/BOARD.md +140 -85
  30. package/docs/v10/CLI-MODERN.md +5 -4
  31. package/docs/v10/DECISIONS.md +1 -0
  32. package/docs/v10/ENGINE.md +2 -0
  33. package/docs/v10/FAILURE-CLASSES.md +3 -3
  34. package/docs/v10/MODERNIZE.md +34 -2
  35. package/docs/v10/PROGRESS.md +15 -0
  36. package/loki-ts/dist/loki.js +1225 -1139
  37. package/mcp/__init__.py +1 -1
  38. package/package.json +2 -2
  39. package/packages/control-plane/dist/server.js +1889 -1577
  40. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  41. package/schemas/loki-yaml.schema.json +5 -0
  42. package/web-app/dist/assets/{AdminPage-D3PgG7SH.js → AdminPage-bZlvH4TL.js} +1 -1
  43. package/web-app/dist/assets/{Avatar-Ct77hvlG.js → Avatar-BZrBstQe.js} +1 -1
  44. package/web-app/dist/assets/{Badge-Cg_gZaJF.js → Badge-Q8l8r4Vq.js} +1 -1
  45. package/web-app/dist/assets/{Button-i9r2G84n.js → Button-B8Bt0A3S.js} +1 -1
  46. package/web-app/dist/assets/{CockpitPage-C3UyLXyp.js → CockpitPage-CIYExrXa.js} +1 -1
  47. package/web-app/dist/assets/{ComparePage-B1dXHga_.js → ComparePage-CBOmHdam.js} +1 -1
  48. package/web-app/dist/assets/{ErrorBoundary-CQ_xREw0.js → ErrorBoundary-BKwDPSnK.js} +1 -1
  49. package/web-app/dist/assets/{EvidenceReceiptPanel-DwUxd_Xi.js → EvidenceReceiptPanel-KqzjphWl.js} +1 -1
  50. package/web-app/dist/assets/{GitHubIssuesPanel-C0jAnuAd.js → GitHubIssuesPanel-lF3GkHRv.js} +1 -1
  51. package/web-app/dist/assets/{GitHubPRsPanel-B4pNXJMz.js → GitHubPRsPanel-BCzipPWD.js} +1 -1
  52. package/web-app/dist/assets/{HomePage-BC9UTS1l.js → HomePage-CIdnkzaN.js} +2 -2
  53. package/web-app/dist/assets/{LoginPage-CnpdwK8F.js → LoginPage-D-178nTT.js} +1 -1
  54. package/web-app/dist/assets/{MagicPage-Bj0lZuNp.js → MagicPage-BmHuzMT8.js} +1 -1
  55. package/web-app/dist/assets/{MetricsPage-yyw3VnfX.js → MetricsPage-CPCiyDFn.js} +1 -1
  56. package/web-app/dist/assets/{NotFoundPage-LMxkJP15.js → NotFoundPage-B2T6n1gx.js} +1 -1
  57. package/web-app/dist/assets/{ProjectPage-2x0MRkNu.js → ProjectPage-DQmpFhb0.js} +3 -3
  58. package/web-app/dist/assets/{ProjectsPage-A8J8o1gj.js → ProjectsPage-BmugzFEd.js} +1 -1
  59. package/web-app/dist/assets/{SettingsPage-fwvOVcRb.js → SettingsPage-ZB-RrmgM.js} +1 -1
  60. package/web-app/dist/assets/{ShowcasePage-DqI4sUwk.js → ShowcasePage-BAhwHt4_.js} +1 -1
  61. package/web-app/dist/assets/{SystemSettingsPage-CyytfNo_.js → SystemSettingsPage-Bt7sf2Vu.js} +1 -1
  62. package/web-app/dist/assets/{TeamsPage-BTTe76W1.js → TeamsPage-B-v5j_8q.js} +1 -1
  63. package/web-app/dist/assets/{TemplatesPage-CIp3OTlL.js → TemplatesPage-bdqUL-LG.js} +1 -1
  64. package/web-app/dist/assets/{TerminalOutput-dX5L4ky-.js → TerminalOutput-CRHEXBwS.js} +1 -1
  65. package/web-app/dist/assets/{activity-uHXr2AaF.js → activity-C-ROkmXt.js} +1 -1
  66. package/web-app/dist/assets/{bell-D6RbxRMo.js → bell-DbiCBxQA.js} +1 -1
  67. package/web-app/dist/assets/{bot-D31w2Mu6.js → bot-DsIgE_qr.js} +1 -1
  68. package/web-app/dist/assets/{check-CoKJksbr.js → check-CkAdmHWP.js} +1 -1
  69. package/web-app/dist/assets/{chevron-left-Bjx7oX7s.js → chevron-left-CYTLivAo.js} +1 -1
  70. package/web-app/dist/assets/{circle-alert-DOAh8Jmi.js → circle-alert-BVIcAsFJ.js} +1 -1
  71. package/web-app/dist/assets/{clock-DgEXPNWH.js → clock-Dg3Rq0go.js} +1 -1
  72. package/web-app/dist/assets/{cloud-0dXJFzBv.js → cloud-CenrqHFl.js} +1 -1
  73. package/web-app/dist/assets/{code-xml-DytQ_bq7.js → code-xml-C2mvfoug.js} +1 -1
  74. package/web-app/dist/assets/{database-Qyjryb72.js → database-BKa0_eCb.js} +1 -1
  75. package/web-app/dist/assets/{dollar-sign-BzhTVy1e.js → dollar-sign-DfelUK1S.js} +1 -1
  76. package/web-app/dist/assets/{file-code-corner-DTdSOALw.js → file-code-corner-CZKWoHVg.js} +1 -1
  77. package/web-app/dist/assets/{file-plus-VdjOG1RK.js → file-plus-CZ-m1zMX.js} +1 -1
  78. package/web-app/dist/assets/{globe-BhxNOfyM.js → globe-S0kv_GwC.js} +1 -1
  79. package/web-app/dist/assets/{hammer-MQzrw8ee.js → hammer-BW1MDdu1.js} +1 -1
  80. package/web-app/dist/assets/{index-DaxrMbHf.js → index-ACEYsTc2.js} +3 -3
  81. package/web-app/dist/assets/{layers-C7nusTP-.js → layers-BrXHYZ-L.js} +1 -1
  82. package/web-app/dist/assets/{loader-circle-B1qBkgOY.js → loader-circle-DOdhKFx0.js} +1 -1
  83. package/web-app/dist/assets/{lock-BdkOxK2O.js → lock-BcFTyg8J.js} +1 -1
  84. package/web-app/dist/assets/{package-RVyK96Zn.js → package-D34cMVQG.js} +1 -1
  85. package/web-app/dist/assets/{plus-ntmwFRrp.js → plus-CEaMeBxL.js} +1 -1
  86. package/web-app/dist/assets/{refresh-cw-BJ39b_PP.js → refresh-cw-D2Rzxr5U.js} +1 -1
  87. package/web-app/dist/assets/{rotate-ccw-uPx_-3vz.js → rotate-ccw-C_-EjRLh.js} +1 -1
  88. package/web-app/dist/assets/{scroll-text-D9qvXkj-.js → scroll-text-BSSCMI_c.js} +1 -1
  89. package/web-app/dist/assets/{server-gs4xmD8Y.js → server-BKvLonWt.js} +1 -1
  90. package/web-app/dist/assets/{shield-alert-O2_YffGI.js → shield-alert-DbbQbPdW.js} +1 -1
  91. package/web-app/dist/assets/{thumbs-up-B3RVBhQx.js → thumbs-up-NoBD1fUR.js} +1 -1
  92. package/web-app/dist/assets/{trash-2-DEUt7qhq.js → trash-2-BXz8rAyd.js} +1 -1
  93. package/web-app/dist/assets/{trending-down-oyTns4UZ.js → trending-down-D8u535Re.js} +1 -1
  94. package/web-app/dist/assets/{trending-up-CdpZK0Oc.js → trending-up-q8k3kgiy.js} +1 -1
  95. package/web-app/dist/assets/{upload-D9sbjjC8.js → upload-Cp7_zeBI.js} +1 -1
  96. package/web-app/dist/assets/{usePolling-6ognrnyV.js → usePolling-CTHVdcuX.js} +1 -1
  97. package/web-app/dist/assets/{user-CBKc8QoI.js → user-DF7bfSf_.js} +1 -1
  98. package/web-app/dist/index.html +1 -1
  99. package/autonomy/lib/doctor-fix.py +0 -279
package/README.md CHANGED
@@ -102,7 +102,7 @@ Time: 4m12s (intake 11s, plan 20s, implement 3m10s, verify 31s)
102
102
  | `loki agents list\|new\|show\|export --claude` | Custom child agents in `.loki/agents/*.md` |
103
103
  | `loki status [run-id]` | Status of the latest run, or of a given run |
104
104
  | `loki verify [run-id]` | Check a receipt's hash, signature and event log |
105
- | `loki answer [run-id]` | Resume a BLOCKED run with your answer (`--text "..."`) |
105
+ | `loki answer [run-id]` | Start a follow-up run from a BLOCKED run with your answer (`--text "..."`) |
106
106
  | `loki doctor [--fix] [--json]` | Check prerequisites, providers and login; `--fix` applies safe fixes |
107
107
  | `loki control serve` | Start the Control Plane (see below) |
108
108
  | `loki dashboard` | Open the Control Plane UI, starting it if needed |
@@ -139,7 +139,7 @@ Required:
139
139
 
140
140
  - Claude Code (`claude`), installed and logged in: the default provider and the only one with every feature. The bundled Agent SDK with `ANTHROPIC_API_KEY` can stand in on the main `loki start` path, but `loki edit` and `loki chat` call the `claude` CLI.
141
141
  - Python 3.8+ (`python3`)
142
- - Node.js 18+ (`node`; 20+ recommended)
142
+ - Node.js 20+ (`node`; 22 LTS is the CI target)
143
143
  - `jq`
144
144
  - Git 2.x (`git`)
145
145
  - `curl`
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: An autonomous software factory that knows what it is supposed to deliver, and proves it did. Use when the user says Loki Mode or asks to build, fix or verify software autonomously.
4
4
  ---
5
5
 
6
- # Loki Mode v11.10.1
6
+ # Loki Mode v11.15.0
7
7
 
8
8
  **Current entry points (Loki 10 engine):** `loki "<task>"`, `loki owner/repo#N` and `loki quick "<task>"` run the Loki 10 engine and end with a signed receipt (`loki verify`). `loki backlog owner/repo --all` runs many issues in parallel. Guide: `docs/v10/GUIDE.md`. The skill instructions below, and `loki start`, describe the previous bash loop. PRD-file starts (`loki start ./prd.md`) and the opencode provider still run on it until FC38-SWEEP ports them; the legacy-engine command and the LOKI_ENGINE switch remain removed. Prefer `loki owner/repo#N` for issues.
9
9
 
@@ -200,7 +200,7 @@ This protocol governs **skill module** loading -- task-scoped instruction files
200
200
  <!-- generated:commands -->
201
201
  Commands (generated from `loki-ts/src/cli/registry.ts`; full reference in `docs/CLI-REFERENCE.md`):
202
202
 
203
- `version`, `status`, `doctor`, `provider`, `memory`, `rollback`, `proof`, `wiki`, `control`, `kpis`, `report`, `trust`, `crash`, `contract`, `start`, `slack`, `queue`, `issues`, `undo`, `verify-pr`, `answer`, `engine10`, `help`, `chat`, `edit`, `diff`, `models`, `setup`, `quick`, `quickstart`, `init`, `template`, `verify`, `keys`, `review`, `dashboard`, `config`, `mcp`, `acp`, `stop`, `pause`, `resume`, `why`, `next`, `logs`, `ship`, `deploy`, `import`, `github`, `issue`, `ci`, `modernize`, `share`, `assets`, `export`, `notify`, `tour`, `welcome`, `onboard`, `setup-skill`, `self-update`, `remote`, `cockpit`, `code`, `context`, `secrets`, `api`, `sandbox`, `docker`, `web`, `preview`, `telemetry`, `syslog`, `explain`, `docs`, `test`, `bench`, `voice`, `own`, `secure`, `compliance`, `enterprise`, `projects`, `audit`, `cost`, `metrics`, `sentrux`, `magic`
203
+ `version`, `status`, `doctor`, `provider`, `memory`, `rollback`, `proof`, `wiki`, `control`, `intent`, `kpis`, `report`, `trust`, `crash`, `contract`, `start`, `slack`, `queue`, `issues`, `undo`, `verify-pr`, `answer`, `engine10`, `help`, `chat`, `edit`, `diff`, `models`, `setup`, `quick`, `quickstart`, `init`, `template`, `verify`, `keys`, `review`, `dashboard`, `config`, `mcp`, `acp`, `stop`, `pause`, `resume`, `why`, `next`, `logs`, `ship`, `deploy`, `import`, `github`, `issue`, `ci`, `modernize`, `share`, `assets`, `export`, `notify`, `tour`, `welcome`, `onboard`, `setup-skill`, `self-update`, `remote`, `cockpit`, `code`, `context`, `secrets`, `api`, `sandbox`, `docker`, `web`, `preview`, `telemetry`, `syslog`, `explain`, `docs`, `test`, `bench`, `voice`, `own`, `secure`, `compliance`, `enterprise`, `projects`, `audit`, `cost`, `metrics`, `sentrux`, `magic`
204
204
  <!-- /generated -->
205
205
 
206
206
  ---
@@ -483,4 +483,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
483
483
 
484
484
  ---
485
485
 
486
- **v11.10.1 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
486
+ **v11.15.0 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 11.10.1
1
+ 11.15.0
@@ -308,9 +308,17 @@ def main(argv):
308
308
  child_env["LOKI_PROVIDER"] = a.provider
309
309
  elif cfg.get("provider"):
310
310
  child_env["LOKI_PROVIDER"] = cfg["provider"]
311
- # ponytail: per-run cap is forwarded only; the v10 engine itself caps on time
312
- if cfg.get("budgets", {}).get("per_run_usd"):
313
- child_env["LOKI_BUDGET_LIMIT"] = str(cfg["budgets"]["per_run_usd"])
311
+ # per-run cap forwarded to each child (budgets.per_run is the TS-side alias); an exported LOKI_BUDGET_LIMIT wins over loki.yaml
312
+ bud = cfg.get("budgets", {})
313
+ per_run = bud.get("per_run_usd") or bud.get("per_run")
314
+ if per_run and not env.get("LOKI_BUDGET_LIMIT"):
315
+ child_env["LOKI_BUDGET_LIMIT"] = str(per_run)
316
+ group_cap = None # a DAG group shares one total: the run cap bounds the sum of its units, not each unit
317
+ if dag_units:
318
+ try:
319
+ group_cap = float(env.get("LOKI_BUDGET_LIMIT") or 0) or None
320
+ except ValueError:
321
+ group_cap = None
314
322
  day_cap = cfg.get("budgets", {}).get("per_day_usd")
315
323
 
316
324
  # One job table for issues and units: they share one slot pool.
@@ -444,6 +452,13 @@ def main(argv):
444
452
  for k in [k for k, (p, _l, _w) in running.items() if p.poll() is not None]:
445
453
  finish(k)
446
454
  while pending and len(running) < conc:
455
+ group_spent = sum(st["cost"] or 0 for st in state.values())
456
+ if group_cap and group_spent >= group_cap:
457
+ for k in pending:
458
+ state[k].update(status="BUDGET_STOP: group budget $%.2f reached ($%.2f spent)" % (group_cap, group_spent), ok=False)
459
+ say("backlog: %s %s" % (spec[k]["label"], state[k]["status"]))
460
+ pending = []
461
+ break
447
462
  spent = spent_today() if day_cap else 0.0
448
463
  if day_cap and (spent is None or spent >= day_cap):
449
464
  for k in pending:
@@ -52,7 +52,9 @@ Loki chat commands:
52
52
  /model <id> set model for this session
53
53
  /provider <name> switch provider (claude, codex, cline, aider, opencode)
54
54
  /cost loki cost
55
+ /merge <args> loki merge (pr <pr>, watch <pr> to repair red CI, run, list)
55
56
  /diff git diff --stat plus diff
57
+ /doctor [--json] loki doctor --repo: repo readiness score and fixes (alias /ready)
56
58
  /undo [args] loki undo
57
59
  /help, /exit
58
60
  Attached mode (loki chat --attach or /attach): /status /steer <msg> /diff /verify /receipt /stop
@@ -355,8 +357,12 @@ Question: $line" || true
355
357
  _chat_do "$loki" verify $rest ;;
356
358
  /receipt) _chat_do "$loki" receipt ;;
357
359
  /cost) _chat_do "$loki" cost ;;
360
+ /merge) # shellcheck disable=SC2086
361
+ _chat_do "$loki" merge $rest ;;
358
362
  /undo) # shellcheck disable=SC2086
359
363
  _chat_do "$loki" undo $rest ;;
364
+ /doctor|/ready) # shellcheck disable=SC2086
365
+ _chat_do "$loki" doctor --repo $rest ;;
360
366
  /diff) _chat_do git diff --stat; _chat_do git diff ;;
361
367
  /model)
362
368
  if [ -n "$rest" ]; then _CHAT_MODEL="$rest"; echo "model: $rest"; else echo "model: ${_CHAT_MODEL:-default}"; fi ;;
@@ -4,7 +4,7 @@
4
4
  # engine10-push.sh push-pr <repo-dir> <branch> <title> <body-file> [--draft] [--base <branch>]
5
5
  # engine10-push.sh check-origin (exit 0 when the pinned origin is acceptable to push-pr, else the refusal; no network)
6
6
  # engine10-push.sh comment <pr-number> <body-file>
7
- # engine10-push.sh issue-comment <issue-ref> <body-file>
7
+ # engine10-push.sh issue-comment [--update] <issue-ref> <body-file>
8
8
  # engine10-push.sh status <sha> <pending|success|failure|error> <description>
9
9
  #
10
10
  # Holds GitHub credentials, runs no LLM, reads no untrusted text. Inputs come
@@ -159,11 +159,14 @@ case "$mode" in
159
159
  fi
160
160
  url="$(_e10_gh pr list --repo "$_e10_repo" --head "$branch" --state open --json url --jq '.[0].url')" \
161
161
  || die "gh pr list failed"
162
+ _e10_pr_state=reused
162
163
  if [ -z "$url" ] || [ "$url" = "null" ]; then
164
+ _e10_pr_state=created
163
165
  url="$(_e10_gh pr create --repo "$_e10_repo" --head "$branch" --title "$title" --body-file "$body" "${draft[@]}" "${base[@]}")" \
164
166
  || die "gh pr create failed"
165
167
  url="$(printf '%s\n' "$url" | tail -n 1)"
166
168
  fi
169
+ printf '%s\n' "$_e10_pr_state" # marker line before the URL: reused (open PR found) or created
167
170
  printf '%s\n' "$url"
168
171
  ;;
169
172
  comment)
@@ -176,11 +179,32 @@ case "$mode" in
176
179
  issue-comment)
177
180
  # E-67: a FAILED run with no diff (or no remote) has no PR to open; comment on the issue
178
181
  # instead so the run's reason is never silently lost. <issue-ref> is owner/repo#N or #N.
179
- [ "$#" -eq 2 ] || die "usage: issue-comment <issue-ref> <body-file>"
182
+ # --update: the status projector. One comment carrying the marker below is edited in place (created when absent),
183
+ # so an issue shows live run state without a new comment per event. Only a comment written by the authenticated
184
+ # gh user counts, and the body file must carry the marker (it is built by the supervisor, never from task text).
185
+ _e10_upd=""
186
+ [ "${1:-}" != "--update" ] || { _e10_upd=1; shift; }
187
+ [ "$#" -eq 2 ] || die "usage: issue-comment [--update] <issue-ref> <body-file>"
180
188
  num="${1##*#}"
181
189
  case "$num" in '' | *[!0-9]*) die "cannot extract issue number from: $1" ;; esac
182
190
  [ -f "$2" ] || die "body file not found: $2"
183
191
  [ -z "$_e10_local" ] || { log_info "local origin: no issue to comment on (no-op)"; exit 0; }
192
+ if [ -n "$_e10_upd" ]; then
193
+ _e10_marker='<!-- loki-status -->'
194
+ grep -qF -- "$_e10_marker" "$2" || die "status body must carry $_e10_marker"
195
+ _e10_me="$(_e10_gh api user --jq .login 2>/dev/null)" || _e10_me=""
196
+ _e10_cid=""
197
+ if [ -n "$_e10_me" ]; then
198
+ _e10_cid="$(_e10_gh api "repos/$_e10_repo/issues/$num/comments" --paginate \
199
+ --jq ".[] | select(.user.login == \"$_e10_me\") | select(.body | contains(\"$_e10_marker\")) | .id" 2>/dev/null | head -n 1)" || _e10_cid=""
200
+ fi
201
+ case "$_e10_cid" in
202
+ '') _e10_gh issue comment "$num" --repo "$_e10_repo" --body-file "$2" || die "gh issue comment failed" ;;
203
+ *[!0-9]*) die "unexpected comment id" ;;
204
+ *) _e10_gh api -X PATCH "repos/$_e10_repo/issues/comments/$_e10_cid" -F "body=@$2" >/dev/null || die "gh api comment update failed" ;;
205
+ esac
206
+ exit 0
207
+ fi
184
208
  _e10_gh issue comment "$num" --repo "$_e10_repo" --body-file "$2" || die "gh issue comment failed"
185
209
  ;;
186
210
  status)
@@ -1441,6 +1441,20 @@ def _collect_journey(loki_dir):
1441
1441
  n = iv.get("count")
1442
1442
  if isinstance(n, int) and n >= 0:
1443
1443
  out["interventions"] = n
1444
+ # by_type / waited_s exist only for counters written by the current
1445
+ # handle_pause. A count-only legacy file yields no detail (unmeasured,
1446
+ # never a fabricated zero). waited_s is wall-clock blocked seconds, an
1447
+ # upper bound on human attention, not active time.
1448
+ bt = iv.get("by_type")
1449
+ ws = iv.get("waited_s")
1450
+ if isinstance(bt, dict) and isinstance(ws, int) and not isinstance(ws, bool) and ws >= 0:
1451
+ out["interventions_detail"] = {
1452
+ "by_type": {
1453
+ k: (bt[k] if isinstance(bt.get(k), int) and not isinstance(bt.get(k), bool) and bt[k] >= 0 else 0)
1454
+ for k in ("steer", "input", "authorization", "repair")
1455
+ },
1456
+ "waited_s": ws,
1457
+ }
1444
1458
 
1445
1459
  # PR state/URL, written by the consent-gated PR step. "prepared" means a PR
1446
1460
  # body exists locally and GitHub was NOT mutated.
@@ -1847,6 +1861,8 @@ def _build_proof(args, loki_dir, target_dir, repo_root):
1847
1861
  # "unavailable" rather than showing a fabricated zero.
1848
1862
  if isinstance(journey, dict) and isinstance(journey.get("interventions"), int):
1849
1863
  proof["interventions"] = journey["interventions"]
1864
+ if isinstance(journey, dict) and isinstance(journey.get("interventions_detail"), dict):
1865
+ proof["interventions_detail"] = journey["interventions_detail"]
1850
1866
 
1851
1867
  return proof, run_id
1852
1868
 
@@ -21,6 +21,14 @@ Direction (up/down/flat) is computed by a median half-split: mean of the later
21
21
  half minus mean of the earlier half. Robust to one noisy run, no float
22
22
  regression needed, and a 2-run series degrades to last-vs-first.
23
23
 
24
+ Outcome effort definitions (shared with loki-ts/src/metrics/trust.ts and
25
+ benchmarks/bench/attribution.py; see docs/EVALUATING.md "Outcome analytics"):
26
+ accepted = verdict VERIFIED and not later contradicted; misleading completion =
27
+ false_verified / VERIFIED-claimed (attribution false_completion); cost per
28
+ accepted = total spend over ALL runs / accepted, None when nothing was accepted
29
+ or any run's cost is unmeasured. Waited seconds are wall-clock blocked time, an
30
+ upper bound on human attention; active minutes are NOT measured.
31
+
24
32
  Public API:
25
33
  compute_trajectory(loki_dir) -> dict (schema_version 1)
26
34
  format_trajectory_human(traj) -> str
@@ -160,6 +168,50 @@ def _interventions_value(proof):
160
168
  return None
161
169
 
162
170
 
171
+ _IV_TYPES = ("steer", "input", "authorization", "repair")
172
+
173
+
174
+ def _cost_value(proof):
175
+ usd = _obj(proof.get("cost")).get("usd")
176
+ if usd is None or isinstance(usd, bool):
177
+ return None
178
+ try:
179
+ n = float(usd)
180
+ except (TypeError, ValueError):
181
+ return None
182
+ return n if n >= 0 and n == n and n != float("inf") else None
183
+
184
+
185
+ def _intervention_detail(proof):
186
+ """(by_type, waited_s) from proof.interventions_detail; None when absent."""
187
+ d = _obj(proof.get("interventions_detail"))
188
+ bt = _obj(d.get("by_type"))
189
+ out = {}
190
+ any_key = False
191
+ for k in _IV_TYPES:
192
+ try:
193
+ n = int(bt.get(k))
194
+ except (TypeError, ValueError):
195
+ n = 0
196
+ out[k] = n if n >= 0 else 0
197
+ if bt.get(k) is not None:
198
+ any_key = True
199
+ w = d.get("waited_s")
200
+ ws = w if isinstance(w, (int, float)) and not isinstance(w, bool) and w >= 0 else None
201
+ return (out if any_key else None), ws
202
+
203
+
204
+ def _verdict_evidence(proof):
205
+ raw = str(proof.get("verdict") or "").strip().upper()
206
+ verdict = raw or None
207
+ later = str(proof.get("later_verdict") or "").strip().upper()
208
+ if proof.get("false_verified") is True:
209
+ return verdict, True
210
+ if verdict is None:
211
+ return verdict, None
212
+ return verdict, verdict == "VERIFIED" and later == "FAILED"
213
+
214
+
163
215
  def _load_runs(loki_dir):
164
216
  """Read every .loki/proofs/<id>/proof.json into a time-ordered run list."""
165
217
  proofs_dir = os.path.join(loki_dir, "proofs")
@@ -176,7 +228,14 @@ def _load_runs(loki_dir):
176
228
  if not isinstance(proof, dict):
177
229
  # Malformed / partial proof: skip, do not fail the trajectory.
178
230
  continue
231
+ verdict, false_verified = _verdict_evidence(proof)
232
+ by_type, waited_s = _intervention_detail(proof)
179
233
  runs.append({
234
+ "verdict": verdict,
235
+ "false_verified": false_verified,
236
+ "cost_usd": _cost_value(proof),
237
+ "interventions_by_type": by_type,
238
+ "waited_s": waited_s,
180
239
  "run_id": str(proof.get("run_id") or name),
181
240
  "generated_at": proof.get("generated_at"),
182
241
  "council_pass_rate": _council_pass_value(proof.get("council")),
@@ -262,6 +321,65 @@ def _direction_for_axis(axis, ordered_values):
262
321
  }
263
322
 
264
323
 
324
+ def outcome_effort(series):
325
+ """Effort-per-outcome block; every unmeasured input yields None, never 0."""
326
+ accepted = sum(1 for r in series if r["verdict"] == "VERIFIED" and r["false_verified"] is not True)
327
+ claimed = sum(1 for r in series if r["verdict"] == "VERIFIED" or r["false_verified"] is True)
328
+ false_v = sum(1 for r in series if r["false_verified"] is True)
329
+ unmeasured = sum(1 for r in series if r["cost_usd"] is None)
330
+ cost = None
331
+ cost_note = None
332
+ if not series:
333
+ cost_note = "no runs"
334
+ elif accepted == 0:
335
+ cost_note = "no accepted run, cost per accepted is undefined"
336
+ elif unmeasured:
337
+ cost_note = "cost unmeasured for %d of %d runs, a partial sum is not a total" % (unmeasured, len(series))
338
+ else:
339
+ cost = round(sum(r["cost_usd"] for r in series) / accepted, 6)
340
+ with_detail = [r for r in series if r["interventions_by_type"] is not None]
341
+ by_type = None
342
+ if with_detail:
343
+ by_type = {k: sum(r["interventions_by_type"][k] for r in with_detail) for k in _IV_TYPES}
344
+ all_iv = bool(series) and all(r["interventions"] is not None for r in series)
345
+ all_waited = bool(series) and all(r["waited_s"] is not None for r in series)
346
+ return {
347
+ "runs": len(series),
348
+ "accepted": accepted,
349
+ "claimed_verified": claimed,
350
+ "false_verified": false_v,
351
+ "misleading_completion_rate": round(false_v / claimed, 4) if claimed else None,
352
+ "cost_per_accepted_usd": cost,
353
+ "cost_note": cost_note,
354
+ "interventions_by_type": by_type,
355
+ "interventions_per_accepted": (
356
+ round(sum(r["interventions"] for r in series) / accepted, 4) if accepted and all_iv else None),
357
+ "waited_min_per_accepted": (
358
+ round(sum(r["waited_s"] for r in series) / 60.0 / accepted, 1) if accepted and all_waited else None),
359
+ }
360
+
361
+
362
+ def _fmt_outcome_effort(e):
363
+ mc = e.get("misleading_completion_rate")
364
+ lines = ["Outcome effort"]
365
+ lines.append(" Accepted (VERIFIED): %d of %d runs" % (e["accepted"], e["runs"]))
366
+ lines.append(" Misleading completion: %s" % (
367
+ "n/a (no VERIFIED claims)" if mc is None else
368
+ "%d of %d VERIFIED claims (%s%%)" % (e["false_verified"], e["claimed_verified"], round(mc * 100, 1))))
369
+ lines.append(" Cost per accepted: %s" % (
370
+ "n/a (%s)" % (e.get("cost_note") or "unmeasured") if e["cost_per_accepted_usd"] is None
371
+ else "$%.2f" % e["cost_per_accepted_usd"]))
372
+ bt = e.get("interventions_by_type")
373
+ lines.append(" Interventions by type: %s" % (
374
+ "not recorded" if bt is None else ", ".join("%s %d" % (k, bt[k]) for k in _IV_TYPES)))
375
+ ipa = e.get("interventions_per_accepted")
376
+ lines.append(" Interventions per accepted: %s" % ("n/a" if ipa is None else ipa))
377
+ w = e.get("waited_min_per_accepted")
378
+ lines.append(" Minutes per accepted: %s" % (
379
+ "n/a" if w is None else "%s (waited, upper bound; active minutes are not measured)" % w))
380
+ return lines
381
+
382
+
265
383
  def compute_trajectory(loki_dir):
266
384
  """Return the R4 trust trajectory snapshot for a project's .loki dir."""
267
385
  runs = _load_runs(loki_dir)
@@ -276,6 +394,11 @@ def compute_trajectory(loki_dir):
276
394
  "gate_pass_rate": r["gate_pass_rate"],
277
395
  "iterations": r["iterations"],
278
396
  "interventions": r["interventions"],
397
+ "cost_usd": r["cost_usd"],
398
+ "interventions_by_type": r["interventions_by_type"],
399
+ "waited_s": r["waited_s"],
400
+ "verdict": r["verdict"],
401
+ "false_verified": r["false_verified"],
279
402
  })
280
403
 
281
404
  axes = {}
@@ -317,6 +440,7 @@ def compute_trajectory(loki_dir):
317
440
  "regressing_axes": regressing_axes,
318
441
  "series": series,
319
442
  "notes": notes,
443
+ "outcome_effort": outcome_effort(series),
320
444
  }
321
445
 
322
446
 
@@ -376,6 +500,9 @@ def format_trajectory_human(traj):
376
500
  lines.append("Not enough history yet.")
377
501
  lines.append("Trust trajectory needs 2+ recorded runs to show a direction.")
378
502
  lines.append("Each `loki start` run writes a proof-of-run; come back after the next run.")
503
+ if traj.get("runs_count") and traj.get("outcome_effort"):
504
+ lines.append("")
505
+ lines.extend(_fmt_outcome_effort(traj["outcome_effort"]))
379
506
  if traj.get("notes"):
380
507
  lines.append("")
381
508
  lines.append("Notes")
@@ -398,6 +525,9 @@ def format_trajectory_human(traj):
398
525
  lines.append("Overall: mixed (%d improving / %d regressing)." % (imp, reg))
399
526
  else:
400
527
  lines.append("Overall: stable.")
528
+ if traj.get("outcome_effort"):
529
+ lines.append("")
530
+ lines.extend(_fmt_outcome_effort(traj["outcome_effort"]))
401
531
  if traj.get("notes"):
402
532
  lines.append("")
403
533
  lines.append("Notes")
@@ -222,8 +222,7 @@ def run_integration(ws, run_dir, worktrees, heads):
222
222
  """Run the integration command once; return the evidence dict."""
223
223
  integ = ws.get("integration") or {}
224
224
  cmd = integ.get("command")
225
- ev = {"heads": heads, "exit_code": None, "log_sha256": None, "status": "not_configured",
226
- "seal": False, "note": "integration evidence is bound by composite.json only when the run seals one"}
225
+ ev = {"heads": heads, "exit_code": None, "log_sha256": None, "status": "not_configured"}
227
226
  if not cmd:
228
227
  return ev
229
228
  timeout = integ.get("timeout_s", 900)
@@ -245,7 +244,7 @@ def run_integration(ws, run_dir, worktrees, heads):
245
244
  p.wait()
246
245
  rc = 124
247
246
  ev["status"] = "failed"
248
- ev["note"] += "; timeout after %ss" % timeout
247
+ ev["note"] = "timeout after %ss" % timeout
249
248
  ev["exit_code"] = rc
250
249
  with open(log_path, "rb") as f:
251
250
  ev["log_sha256"] = hashlib.sha256(f.read()).hexdigest()