loki-mode 11.17.1 → 11.17.6

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 (109) hide show
  1. package/SKILL.md +3 -3
  2. package/VERSION +1 -1
  3. package/autonomy/grill.sh +24 -2
  4. package/autonomy/hooks/precompact-flush.sh +51 -0
  5. package/autonomy/hooks/validate-bash.sh +895 -5
  6. package/autonomy/lib/chat.sh +8 -3
  7. package/autonomy/lib/claude-flags.sh +143 -6
  8. package/autonomy/lib/claude_permissions.py +24 -4
  9. package/autonomy/lib/decision_record.py +92 -3
  10. package/autonomy/lib/efficiency_cost.py +102 -0
  11. package/autonomy/lib/modernize/py_capture.py +62 -3
  12. package/autonomy/lib/prd-enrich.sh +28 -2
  13. package/autonomy/lib/voter-agents.sh +61 -6
  14. package/autonomy/lib/wiki-generator.py +58 -10
  15. package/autonomy/lib/wiki_llm.py +41 -2
  16. package/autonomy/loki +391 -28
  17. package/autonomy/quickstart.sh +1 -1
  18. package/autonomy/run.sh +201 -10
  19. package/autonomy/trigger-server.py +70 -2
  20. package/completions/_loki +3 -0
  21. package/completions/loki.bash +2 -1
  22. package/dashboard/__init__.py +1 -1
  23. package/dashboard/audit.py +204 -17
  24. package/docs/CLI-REFERENCE.md +4 -1
  25. package/docs/EVALUATING.md +2 -2
  26. package/docs/INSTALLATION.md +1 -1
  27. package/docs/environment-variables.md +1 -0
  28. package/docs/trackers.md +6 -0
  29. package/docs/v10/BOARD.md +7 -4
  30. package/docs/v10/CLI-MODERN.md +8 -5
  31. package/docs/v10/DECISIONS.md +31 -1
  32. package/docs/v10/ENGINE.md +1 -0
  33. package/docs/v10/FAILURE-CLASSES.md +60 -1
  34. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  35. package/docs/v10/GUIDE-MODERNIZE.md +9 -4
  36. package/docs/v10/PLAN-V12.md +896 -0
  37. package/docs/v10/PROGRESS.md +10 -0
  38. package/docs/v11/COST-HALF.md +3 -1
  39. package/docs/visual-evidence.md +2 -0
  40. package/loki-ts/dist/loki.js +1104 -1057
  41. package/mcp/__init__.py +1 -1
  42. package/memory/retrieval.py +15 -3
  43. package/memory/schemas.py +43 -1
  44. package/memory/storage.py +11 -0
  45. package/package.json +2 -2
  46. package/packages/control-plane/dist/server.js +989 -693
  47. package/packages/control-plane/src/server/legacy/routes-control.ts +5 -1
  48. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  49. package/providers/claude.sh +1 -0
  50. package/providers/loader.sh +26 -0
  51. package/providers/model_routes.sh +31 -0
  52. package/src/policies/claude_permissions.js +20 -2
  53. package/web-app/dist/assets/{AdminPage-OVzN6eLm.js → AdminPage-CCedYmn3.js} +1 -1
  54. package/web-app/dist/assets/{Avatar-hCCrxrCZ.js → Avatar-DMrDsuGe.js} +1 -1
  55. package/web-app/dist/assets/{Badge-CDcpkhbJ.js → Badge-K4Ui2tJz.js} +1 -1
  56. package/web-app/dist/assets/{Button-8mwWlhEu.js → Button-Bhjts5QZ.js} +1 -1
  57. package/web-app/dist/assets/{CockpitPage-D8cf3u22.js → CockpitPage-Be7sEVqh.js} +1 -1
  58. package/web-app/dist/assets/{ComparePage-CIgkbDRY.js → ComparePage-D4ty3jiF.js} +1 -1
  59. package/web-app/dist/assets/{ErrorBoundary-99kJH9g2.js → ErrorBoundary-Cmt2x_mL.js} +1 -1
  60. package/web-app/dist/assets/{EvidenceReceiptPanel-C9fj32eS.js → EvidenceReceiptPanel-D2IXLnp9.js} +1 -1
  61. package/web-app/dist/assets/{GitHubIssuesPanel-De43YQ_C.js → GitHubIssuesPanel-CyXitr6h.js} +1 -1
  62. package/web-app/dist/assets/{GitHubPRsPanel-7g7ZTnkx.js → GitHubPRsPanel-RRM3H-xd.js} +1 -1
  63. package/web-app/dist/assets/{HomePage-CcNA24ny.js → HomePage-CMtrrgfs.js} +2 -2
  64. package/web-app/dist/assets/{LoginPage-BYjlb-IK.js → LoginPage-BrzNhnMJ.js} +1 -1
  65. package/web-app/dist/assets/{MagicPage-C0Dwt9Na.js → MagicPage-D7VynOKM.js} +1 -1
  66. package/web-app/dist/assets/{MetricsPage-jDGmdcgg.js → MetricsPage-DJ-gDbfP.js} +1 -1
  67. package/web-app/dist/assets/{NotFoundPage-C_ZO8tLE.js → NotFoundPage-BJh0XVTj.js} +1 -1
  68. package/web-app/dist/assets/{ProjectPage-B5wnurfQ.js → ProjectPage-CAodVUpE.js} +3 -3
  69. package/web-app/dist/assets/{ProjectsPage--EyKt5-D.js → ProjectsPage-m3siS0Rf.js} +1 -1
  70. package/web-app/dist/assets/{SettingsPage-cB0lnfti.js → SettingsPage-CFGgjOvy.js} +1 -1
  71. package/web-app/dist/assets/{ShowcasePage-Dl16FDkK.js → ShowcasePage-Dd9tpSKJ.js} +1 -1
  72. package/web-app/dist/assets/{SystemSettingsPage-BmAXd4V2.js → SystemSettingsPage-DXd0PtTI.js} +1 -1
  73. package/web-app/dist/assets/{TeamsPage-BFkWVOZe.js → TeamsPage-C5KaLIGW.js} +1 -1
  74. package/web-app/dist/assets/{TemplatesPage-Bf2kmEUM.js → TemplatesPage-CDbzE0ZZ.js} +1 -1
  75. package/web-app/dist/assets/{TerminalOutput-BItv9Wa6.js → TerminalOutput-CKG79r_-.js} +1 -1
  76. package/web-app/dist/assets/{activity-Df2Awg6I.js → activity-BaQQRhDp.js} +1 -1
  77. package/web-app/dist/assets/{bell-D-1ztTsJ.js → bell-OeFqvU4e.js} +1 -1
  78. package/web-app/dist/assets/{bot-A4qgZsKi.js → bot-CSxaA0KU.js} +1 -1
  79. package/web-app/dist/assets/{check-BPfFpF0c.js → check-DJml2Xdn.js} +1 -1
  80. package/web-app/dist/assets/{chevron-left-CfSJvmDi.js → chevron-left-DGnt-DtK.js} +1 -1
  81. package/web-app/dist/assets/{circle-alert-wx2d1dzb.js → circle-alert-BV5rVL7K.js} +1 -1
  82. package/web-app/dist/assets/{clock-pQMai6l-.js → clock-CZ7SvULz.js} +1 -1
  83. package/web-app/dist/assets/{cloud-DWL470r6.js → cloud-lkIzrZps.js} +1 -1
  84. package/web-app/dist/assets/{code-xml-BaEQpQLZ.js → code-xml-DzA2wL8F.js} +1 -1
  85. package/web-app/dist/assets/{database-h-N7WkQN.js → database-CLFG5upC.js} +1 -1
  86. package/web-app/dist/assets/{dollar-sign-B5oYiD27.js → dollar-sign-DgWePGsv.js} +1 -1
  87. package/web-app/dist/assets/{file-code-corner-fbTtTy0o.js → file-code-corner-DGQX2bmv.js} +1 -1
  88. package/web-app/dist/assets/{file-plus-PaF6HMo8.js → file-plus-LkaXeYd6.js} +1 -1
  89. package/web-app/dist/assets/{globe-Ct-GlWID.js → globe-rSsEzPYv.js} +1 -1
  90. package/web-app/dist/assets/{hammer-C1oZFX51.js → hammer-CPogONt9.js} +1 -1
  91. package/web-app/dist/assets/{index-C2egrRhz.js → index-DpBLuMuz.js} +3 -3
  92. package/web-app/dist/assets/{layers-Dt5ZK4v2.js → layers-CwFaqidn.js} +1 -1
  93. package/web-app/dist/assets/{loader-circle-DrpMziic.js → loader-circle-C43izEm5.js} +1 -1
  94. package/web-app/dist/assets/{lock-DOez8MmK.js → lock-Bc_Uqxpp.js} +1 -1
  95. package/web-app/dist/assets/{package-ClGIkerr.js → package-BM25lcR-.js} +1 -1
  96. package/web-app/dist/assets/{plus-C004i9ua.js → plus-D0xoiY6N.js} +1 -1
  97. package/web-app/dist/assets/{refresh-cw-Ddfk-7Sy.js → refresh-cw-COI1R5Oo.js} +1 -1
  98. package/web-app/dist/assets/{rotate-ccw-Bu1KgDjS.js → rotate-ccw-BoNZx4xe.js} +1 -1
  99. package/web-app/dist/assets/{scroll-text-3q1lW5pm.js → scroll-text-Cakp8XHd.js} +1 -1
  100. package/web-app/dist/assets/{server-pd6x4HW1.js → server-CjfIlPmE.js} +1 -1
  101. package/web-app/dist/assets/{shield-alert-B9jGJepT.js → shield-alert-BilhziJF.js} +1 -1
  102. package/web-app/dist/assets/{thumbs-up-Cdcc9nQf.js → thumbs-up-CglMrtrt.js} +1 -1
  103. package/web-app/dist/assets/{trash-2-foSojXHK.js → trash-2-B6zVvnoG.js} +1 -1
  104. package/web-app/dist/assets/{trending-down-CTTiqlJ1.js → trending-down-BVhwD9bt.js} +1 -1
  105. package/web-app/dist/assets/{trending-up-B_MgmRAx.js → trending-up-CVJ1ox6c.js} +1 -1
  106. package/web-app/dist/assets/{upload-g6BfALzG.js → upload-DmuR09WL.js} +1 -1
  107. package/web-app/dist/assets/{usePolling-DrHwvzuh.js → usePolling-BTALfO91.js} +1 -1
  108. package/web-app/dist/assets/{user-COp75m7l.js → user-CRdHMZNH.js} +1 -1
  109. package/web-app/dist/index.html +1 -1
package/autonomy/loki CHANGED
@@ -134,15 +134,15 @@ _loki_caveman_narration_level() {
134
134
 
135
135
  # P12-26b: permission flags for the migrate/doc/heal claude launches. Lazily sources
136
136
  # claude-flags.sh (like the caveman wrapper above) and fills _LOKI_PERM_ARGV; unset
137
- # LOKI_PERMISSION_POLICY keeps --dangerously-skip-permissions byte-identically.
137
+ # LOKI_PERMISSION_POLICY=legacy keeps --dangerously-skip-permissions byte-identically.
138
138
  _loki_perm_argv_for() {
139
139
  _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
140
- # Unset policy: return before sourcing anything (claude-flags.sh exports caveman vars).
141
- [ "${LOKI_PERMISSION_POLICY:-}" = "compiled" ] || return 0
140
+ # Legacy policy: return before sourcing anything (claude-flags.sh exports caveman vars).
141
+ [ "${LOKI_PERMISSION_POLICY:-}" = "legacy" ] && return 0
142
142
  _LOKI_PERM_ARGV=(--permission-mode plan)
143
143
  local _pa_lib="${_LOKI_SCRIPT_DIR}/lib/claude-flags.sh" _pa_out _pa_a
144
144
  if [ ! -f "$_pa_lib" ]; then
145
- echo "[loki] WARNING: LOKI_PERMISSION_POLICY=compiled: helper missing; using most restrictive mode (plan)" >&2
145
+ echo "[loki] WARNING: compiled permission policy: helper missing; using most restrictive mode (plan)" >&2
146
146
  return 0
147
147
  fi
148
148
  # Subshell so the helper's top-level exports never leak into this shell.
@@ -1825,6 +1825,7 @@ show_help_all() {
1825
1825
  echo " stop Stop execution immediately"
1826
1826
  echo " pause Pause after current session"
1827
1827
  echo " resume Resume a paused or interrupted run (after Ctrl-C or a crash)"
1828
+ echo " cancel Cancel the active Loki 10 run at its next stage boundary"
1828
1829
  echo " steer \"<note>\" Nudge a running build (Loki 10 engine; legacy loop needs LOKI_PROMPT_INJECTION=true)"
1829
1830
  echo ""
1830
1831
  echo "Verify / trust:"
@@ -1957,7 +1958,7 @@ show_help_all() {
1957
1958
  echo ""
1958
1959
  echo "All commands:"
1959
1960
  echo " agents chat diff edit models route"
1960
- echo " acp agent analyze api assets audit backlog bench checkpoint (cp) ci cleanup"
1961
+ echo " acp agent analyze api assets audit backlog backup bench cancel checkpoint (cp) ci cleanup"
1961
1962
  echo " cluster cockpit code compliance completions compound config context (ctx)"
1962
1963
  echo " cost council crash dashboard demo deploy docker docs doctor dogfood"
1963
1964
  echo " enterprise estimate explain export failover github grill heal help"
@@ -1965,7 +1966,7 @@ show_help_all() {
1965
1966
  echo " issue keys kpis logs magic mcp memory metrics migrate modernize monitor"
1966
1967
  echo " next notify onboard open optimize otel outcomes own (handoff) pause plan preview"
1967
1968
  echo " projects proof (receipt) provider quick quickstart rc remote report reset"
1968
- echo " merge resume review rollback run sandbox secrets secure self-update sentrux"
1969
+ echo " merge restore resume review rollback run sandbox secrets secure self-update sentrux"
1969
1970
  echo " serve setup setup-skill share ship spec start state stats status steer stop"
1970
1971
  echo " syslog telemetry template test tour trigger trust trust-metrics"
1971
1972
  echo " ultracode undo update verify version voice watch watchdog web welcome why"
@@ -2547,23 +2548,33 @@ _loki_start_handoff() {
2547
2548
  }
2548
2549
 
2549
2550
  # Start Loki Mode
2550
- # Every value-taking cmd_start flag (its value is never brief text or a run id).
2551
- # The one list: the --workspace brief scan and the --resume scan both use it.
2552
- # It also names value flags that are not cmd_start's own, so a scan never
2553
- # mistakes their value for a brief or run id. --model is honored nowhere (model
2554
- # routing is LOKI_MODEL_ROUTE); --max-cost only via the engine10 rewrite.
2555
- _loki_start_flag_takes_value() {
2556
- case "$1" in
2557
- --provider|--brief|--budget|--prd|--issue|--isolation|--aider-model|--aider-flags|--cline-model|--compliance|--max-duration|--log-level|--bmad-project|--openspec|--mirofish-rounds|--mirofish-timeout|--output|--config|--vars|--env-file|--remote|--attempts|--mirofish-docker|--spec|--model|--max-cost|--max-wait|--base-wait|--max-iterations|--max-retries|--budget-limit|--session-model|--completion-promise) return 0 ;;
2551
+ # The ONE allowlist of cmd_start flags. _loki_start_flag_kind sets
2552
+ # _LOKI_START_FLAG_KIND to value | optional | bool, or returns 1 for an unknown
2553
+ # flag. The --workspace brief scan and the --resume scan both read it, so a value
2554
+ # is never mistaken for a brief or run id and an unknown flag exits 64 like the
2555
+ # parser's own catch-all. tests/test-cli-allow-haiku-flag.sh checks it against the
2556
+ # cmd_start case arms, so the parser and this list cannot drift apart. --model and
2557
+ # the other Bun/engine10 flags are handled by bin/loki before cmd_start runs, so
2558
+ # they are unknown to cmd_start's pre-scans by design.
2559
+ _loki_start_flag_kind() {
2560
+ _LOKI_START_FLAG_KIND=""
2561
+ case "${1%%=*}" in
2562
+ --provider|--brief|--budget|--prd|--issue|--isolation|--aider-model|--aider-flags|--cline-model|--compliance|--max-duration|--log-level|--bmad-project|--openspec|--mirofish-rounds|--mirofish-timeout|--output|--config|--vars|--env-file|--remote|--attempts|--mirofish-docker) _LOKI_START_FLAG_KIND=value ;;
2563
+ --mirofish) _LOKI_START_FLAG_KIND=optional ;;
2564
+ --parallel|--allow-haiku|--regen-prd|--regenerate-prd|--regen|--fresh-prd|--bg|--background|--simple|--complex|--github|--no-dashboard|--api|--sandbox|--skip-memory|--yes|-y|--quiet|--mirofish-bg|--no-mirofish|--no-plan|--fast|--repo-fast|--full|--repo-full|--dry-run|--no-start|--worktree|-w|--pr|--prepare-pr|--ship|--detach|-d|--help|-h|--resume|--) _LOKI_START_FLAG_KIND=bool ;;
2565
+ *) return 1 ;;
2558
2566
  esac
2559
- return 1
2567
+ # A "--flag=value" token carries its own value, so it never consumes the next one.
2568
+ if [[ "$1" == --*=* ]]; then _LOKI_START_FLAG_KIND=bool; fi
2569
+ return 0
2560
2570
  }
2561
2571
 
2562
2572
  # Optional-value cmd_start flags (--mirofish [URL]). The one decision on whether
2563
2573
  # the NEXT token is the flag's value: non-empty and not another --flag. The
2564
2574
  # cmd_start --mirofish arm and both scans use it, so they cannot disagree.
2565
- _loki_start_flag_optional_value() { [ "$1" = "--mirofish" ]; }
2566
2575
  _loki_start_optval_consumes() { [[ -n "${1:-}" && "${1:-}" != --* ]]; }
2576
+ # Same message and exit code as the cmd_start parser's catch-all arm.
2577
+ _loki_start_unknown_flag() { echo -e "${RED}Unknown option: $1${NC}"; exit 64; }
2567
2578
 
2568
2579
  # One exit-code mapping for an engine10 resume, shared by cmd_resume and
2569
2580
  # `loki start --resume`. 0 and 4 (phase reported) -> 0, 5 (publish failed) -> 1.
@@ -2648,8 +2659,13 @@ cmd_start() {
2648
2659
  case "$_ps_a" in
2649
2660
  --brief=*) _ps_brief+=("${_ps_a#*=}") ;;
2650
2661
  --brief) _ps_skip=2 ;;
2662
+ # bin/loki turns --model into --session-model and forwards --max-cost on
2663
+ # the legacy route; a workspace start ignores both. Skip them and their value.
2664
+ --session-model|--max-cost) _ps_skip=1 ;;
2665
+ --session-model=*|--max-cost=*) ;;
2651
2666
  # Every value-taking cmd_start flag: its value is never brief text.
2652
- -*) if _loki_start_flag_takes_value "$_ps_a"; then _ps_skip=1; elif _loki_start_flag_optional_value "$_ps_a"; then _ps_skip=3; fi ;;
2667
+ -*) _loki_start_flag_kind "$_ps_a" || _loki_start_unknown_flag "$_ps_a"
2668
+ if [ "$_LOKI_START_FLAG_KIND" = value ]; then _ps_skip=1; elif [ "$_LOKI_START_FLAG_KIND" = optional ]; then _ps_skip=3; fi ;;
2653
2669
  *) _ps_brief+=("$_ps_a") ;;
2654
2670
  esac
2655
2671
  done
@@ -2677,17 +2693,22 @@ cmd_start() {
2677
2693
  # P12-07b: a bare `loki start --resume [run-id]` goes to the same engine10
2678
2694
  # resume path as `loki resume` (util/run_outputs.ts resumeMain), never into a
2679
2695
  # new build. Exit codes go through _loki_e10_resume_map (0 and 4 -> 0, 5 -> 1); any other code, such as 3 for nothing to resume, is returned as is.
2680
- local _ps_resume=0 _ps_resume_args=() _ps_rskip=0
2696
+ local _ps_resume=0 _ps_resume_args=() _ps_rskip=0 _ps_unknown=""
2681
2697
  for _ps_a in ${_ps_rest[@]+"${_ps_rest[@]}"}; do
2682
2698
  if [ "$_ps_rskip" = 1 ]; then _ps_rskip=0; continue; fi
2683
2699
  if [ "$_ps_rskip" = 3 ]; then _ps_rskip=0; if _loki_start_optval_consumes "$_ps_a"; then continue; fi; fi
2684
2700
  case "$_ps_a" in
2685
2701
  --resume) _ps_resume=1 ;;
2686
- --*=*) ;;
2687
- -*) if _loki_start_flag_takes_value "$_ps_a"; then _ps_rskip=1; elif _loki_start_flag_optional_value "$_ps_a"; then _ps_rskip=3; fi ;;
2702
+ -*) if ! _loki_start_flag_kind "$_ps_a"; then
2703
+ [ -n "$_ps_unknown" ] || _ps_unknown="$_ps_a"
2704
+ elif [ "$_LOKI_START_FLAG_KIND" = value ]; then _ps_rskip=1
2705
+ elif [ "$_LOKI_START_FLAG_KIND" = optional ]; then _ps_rskip=3
2706
+ fi ;;
2688
2707
  *) _ps_resume_args+=("$_ps_a") ;;
2689
2708
  esac
2690
2709
  done
2710
+ # Without --resume the cmd_start parser reports the same error itself.
2711
+ if [ "$_ps_resume" = 1 ] && [ -n "$_ps_unknown" ]; then _loki_start_unknown_flag "$_ps_unknown"; fi
2691
2712
  if [ "$_ps_resume" = 1 ]; then
2692
2713
  local _ps_rrc=0
2693
2714
  for _ps_a in ${_ps_resume_args[@]+"${_ps_resume_args[@]}"}; do
@@ -5098,6 +5119,39 @@ cmd_cleanup() {
5098
5119
  fi
5099
5120
  }
5100
5121
 
5122
+ # P12-10b: Loki 10 run control. True when the newest engine10 run has no run.completed yet. A pending request
5123
+ # ("$1" = pause) additionally requires control-request.json to hold that action.
5124
+ _loki_e10_active_run() {
5125
+ local last="" d
5126
+ for d in "$LOKI_DIR"/runs/e10-*; do [ -d "$d" ] && last="$d"; done
5127
+ [ -n "$last" ] && [ -f "$last/events.jsonl" ] || return 1
5128
+ ! grep -q '"type":"run.completed"' "$last/events.jsonl" 2>/dev/null || return 1
5129
+ [ -z "${1:-}" ] || grep -q "\"action\":\"$1\"" "$last/control-request.json" 2>/dev/null
5130
+ }
5131
+
5132
+ # loki cancel: asks the active Loki 10 run to stop at its next stage boundary; the run ends CANCELLED with no PR.
5133
+ cmd_cancel() {
5134
+ case "${1:-}" in
5135
+ --help|-h)
5136
+ echo -e "${BOLD}Loki Mode -- cancel the active Loki 10 run${NC}"
5137
+ echo ""
5138
+ echo "Usage: loki cancel"
5139
+ echo ""
5140
+ echo "Writes a cancel request that the engine reads at the next stage boundary."
5141
+ echo "The run then completes as CANCELLED (run.completed with cancelled:true)."
5142
+ echo ""
5143
+ echo "See also: loki pause, loki resume, loki stop"
5144
+ return 0
5145
+ ;;
5146
+ esac
5147
+ if _loki_e10_active_run; then
5148
+ ( LOKI_DIR="$LOKI_DIR" _loki_bun_delegate cancel )
5149
+ return $?
5150
+ fi
5151
+ echo -e "${YELLOW}No active Loki 10 run to cancel.${NC}"
5152
+ return 1
5153
+ }
5154
+
5101
5155
  # Pause after current session
5102
5156
  cmd_pause() {
5103
5157
  # v7.6.2 B-13 fix: --help must print help, not act on session state.
@@ -5123,6 +5177,13 @@ cmd_pause() {
5123
5177
  exit 0
5124
5178
  fi
5125
5179
 
5180
+ # P12-10b: an active Loki 10 run takes a stage-boundary request; anything else keeps the legacy PAUSE file.
5181
+ if _loki_e10_active_run; then
5182
+ local _pz_rc=0
5183
+ ( LOKI_DIR="$LOKI_DIR" _loki_bun_delegate pause ) || _pz_rc=$?
5184
+ [ "$_pz_rc" -ne 0 ] || return 0
5185
+ fi
5186
+
5126
5187
  if is_session_running; then
5127
5188
  # Warn if running in perpetual mode where PAUSE is auto-cleared (#84)
5128
5189
  local current_mode="${LOKI_AUTONOMY_MODE:-perpetual}"
@@ -5379,6 +5440,13 @@ cmd_resume() {
5379
5440
  exit 0
5380
5441
  fi
5381
5442
 
5443
+ # P12-10b: a Loki 10 run paused by `loki pause` is released by clearing its request.
5444
+ if _loki_e10_active_run pause; then
5445
+ local _rz_rc=0
5446
+ ( LOKI_DIR="$LOKI_DIR" _loki_bun_delegate resume ) || _rz_rc=$?
5447
+ [ "$_rz_rc" -ne 0 ] || return 0
5448
+ fi
5449
+
5382
5450
  # RECOVERY: an interrupted engine10 run is resumed at its phase by loki-ts/src/util/run_outputs.ts
5383
5451
  # (exit 0 resumed, 4 phase reported, 5 publish failed, anything else: nothing for it, fall through).
5384
5452
  if ls "$LOKI_DIR"/runs/e10-*/events.jsonl >/dev/null 2>&1; then
@@ -6646,6 +6714,22 @@ cmd_status() {
6646
6714
  fi
6647
6715
  fi
6648
6716
 
6717
+ # P12-19 (D04): freshness of the newest Loki 10 run, same wording as the Bun route
6718
+ # ("last event Ns ago (seq N)"). Display-only; silent when there is no run or no parseable event.
6719
+ local _e10_last _e10_ev _e10_seq _e10_ts _e10_epoch
6720
+ _e10_last=$(ls -d "$LOKI_DIR"/runs/e10-*/ 2>/dev/null | sort | tail -1) || _e10_last=""
6721
+ _e10_ev="${_e10_last%/}/events.jsonl"
6722
+ if [ -n "$_e10_last" ] && [ -s "$_e10_ev" ]; then
6723
+ _e10_seq=$(tail -n 1 "$_e10_ev" | jq -r '.seq // empty' 2>/dev/null) || _e10_seq=""
6724
+ _e10_ts=$(tail -n 1 "$_e10_ev" | jq -r '.ts // empty' 2>/dev/null) || _e10_ts=""
6725
+ _e10_epoch=$(python3 -c "import sys,datetime; print(int(datetime.datetime.fromisoformat(sys.argv[1].replace('Z','+00:00')).timestamp()))" "$_e10_ts" 2>/dev/null) || _e10_epoch=""
6726
+ if [ -n "$_e10_seq" ] && [ -n "$_e10_epoch" ]; then
6727
+ local _e10_age=$(( $(date +%s) - _e10_epoch ))
6728
+ [ "$_e10_age" -lt 0 ] && _e10_age=0
6729
+ echo -e "${CYAN}Freshness:${NC} last event ${_e10_age}s ago (seq ${_e10_seq})"
6730
+ fi
6731
+ fi
6732
+
6649
6733
  # v7.82: surface the single most valuable, most shareable fact -- where the
6650
6734
  # built app is running -- when the app runner reports a live app. Display-only
6651
6735
  # read of .loki/app-runner/state.json (same shape build_completion_summary
@@ -9889,7 +9973,7 @@ _deploy_latest_receipt() {
9889
9973
  return 0
9890
9974
  }
9891
9975
 
9892
- # _deploy_record <dir> <command> <exit_code> <run_id> <anchor_sha> [state] [health_status] [health_url] [rb_attempted] [rb_exit] [receipt_sha256] [started_at] [finished_at] [target] [canary] [post_rollback_health] [health_http_code] [health_match] [tree_sha]
9976
+ # _deploy_record <dir> <command> <exit_code> <run_id> <anchor_sha> [state] [health_status] [health_url] [rb_attempted] [rb_exit] [receipt_sha256] [started_at] [finished_at] [target] [canary] [post_rollback_health] [health_http_code] [health_match] [tree_sha] [destination]
9893
9977
  # Writes the executed deploy's own record, then signs it (loki.deploy/1, Ed25519 via the
9894
9978
  # receipt signer in loki-ts/src/util/receipt_signer.ts) so it is bound to the receipt that
9895
9979
  # authorized it; `loki verify <record>` re-checks it offline. An unsigned record is reported
@@ -9912,7 +9996,7 @@ _deploy_record() {
9912
9996
  local health_status="${7:-unchecked}" health_url="${8:-}"
9913
9997
  local rb_attempted="${9:-false}" rb_exit="${10:-}"
9914
9998
  local receipt_sha="${11:-}" started_at="${12:-}" finished_at="${13:-}" target="${14:-}" canary="${15:-false}"
9915
- local post_rb="${16:-unchecked}" hcode_rec="${17:-}" hmatch="${18:-}" tree_sha="${19:-}"
9999
+ local post_rb="${16:-unchecked}" hcode_rec="${17:-}" hmatch="${18:-}" tree_sha="${19:-}" dest_rec="${20:-}"
9916
10000
  local ddir="$dir/.loki/deploys"
9917
10001
  [ -d "${LOKI_DIR:-}" ] && ddir="${LOKI_DIR}/deploys"
9918
10002
  mkdir -p "$ddir" 2>/dev/null || return 0
@@ -9920,7 +10004,7 @@ _deploy_record() {
9920
10004
  DR_CMD="$command" DR_RC="$rc" DR_RUN="$run_id" DR_ANCHOR="$anchor" DR_OUT="$f" \
9921
10005
  DR_STATE="$anchor_state" DR_HS="$health_status" DR_HU="$health_url" \
9922
10006
  DR_RBA="$rb_attempted" DR_RBX="$rb_exit" DR_RSHA="$receipt_sha" DR_T0="$started_at" \
9923
- DR_T1="$finished_at" DR_TGT="$target" DR_CAN="$canary" DR_PRB="$post_rb" DR_HC="$hcode_rec" DR_HM="$hmatch" DR_TREE="$tree_sha" \
10007
+ DR_T1="$finished_at" DR_TGT="$target" DR_CAN="$canary" DR_PRB="$post_rb" DR_HC="$hcode_rec" DR_HM="$hmatch" DR_TREE="$tree_sha" DR_DEST="$dest_rec" \
9924
10008
  python3 -c '
9925
10009
  import json, os, datetime
9926
10010
  state = os.environ.get("DR_STATE") or "anchored"
@@ -9949,6 +10033,7 @@ json.dump({
9949
10033
  "post_rollback_health": os.environ.get("DR_PRB") or "unchecked",
9950
10034
  "health_match": os.environ.get("DR_HM", ""),
9951
10035
  "tree_sha": os.environ.get("DR_TREE", ""),
10036
+ "destination": os.environ.get("DR_DEST", ""),
9952
10037
  "health_http_code": int(os.environ["DR_HC"]) if os.environ.get("DR_HC", "").isdigit() else None,
9953
10038
  }, open(os.environ["DR_OUT"], "w"), indent=2)
9954
10039
  ' 2>/dev/null || return 0
@@ -10097,6 +10182,26 @@ _deploy_policy_refusal() {
10097
10182
  return 0
10098
10183
  }
10099
10184
 
10185
+ # _deploy_staging_receipt_ok <dir> <sha>
10186
+ # Returns 0 only when a staging deploy record for exactly <sha> exists in
10187
+ # .loki/deploys/, exited 0, did not fail its health probe, and `loki verify`
10188
+ # reports VERIFIED. Anything that cannot be evaluated is "no" (fail closed).
10189
+ _deploy_staging_receipt_ok() {
10190
+ local dir="${1:-.}" sha="${2:-}" ddir="$1/.loki/deploys" f
10191
+ [ -n "$sha" ] || return 1
10192
+ [ -d "${LOKI_DIR:-}" ] && ddir="${LOKI_DIR}/deploys"
10193
+ [ -d "$ddir" ] || return 1
10194
+ for f in "$ddir"/*.json; do
10195
+ [ -f "$f" ] || continue
10196
+ python3 -c 'import json,sys
10197
+ r=json.load(open(sys.argv[1]))
10198
+ ok=r.get("schema")=="loki.deploy/1" and r.get("destination")=="staging" and r.get("anchor_sha")==sys.argv[2] and r.get("exit_code")==0 and r.get("health_status")!="FAILED_HEALTH"
10199
+ sys.exit(0 if ok else 1)' "$f" "$sha" 2>/dev/null || continue
10200
+ ( _loki_bun_delegate engine10 verify "$f" 2>&1 ) | grep -q "^verdict: VERIFIED" && return 0
10201
+ done
10202
+ return 1
10203
+ }
10204
+
10100
10205
  # _deploy_try_execute <dir> <type>
10101
10206
  # The ONLY place a deploy command is ever run. Evaluates the gate, and either
10102
10207
  # executes + records, or REFUSES naming every failed check. Returns the deploy
@@ -10133,6 +10238,19 @@ _deploy_try_execute() {
10133
10238
  esac
10134
10239
  fi
10135
10240
 
10241
+ # preview/staging use a non-production verb; only Vercel documents one here.
10242
+ local dest="${_LOKI_DEPLOY_DESTINATION:-}"
10243
+ if [ "$dest" = preview ] || [ "$dest" = staging ]; then
10244
+ case "$command" in
10245
+ "vercel --prod") exec_cmd="vercel" ;;
10246
+ *)
10247
+ echo -e "${RED}REFUSED: --destination ${dest} is not supported for: ${command}${NC}" >&2
10248
+ echo " No non-production verb is documented for this provider (Vercel: 'vercel' without --prod). Nothing was run." >&2
10249
+ return 1
10250
+ ;;
10251
+ esac
10252
+ fi
10253
+
10136
10254
  local reasons
10137
10255
  reasons="$(_deploy_gate "$dir" "$command")"
10138
10256
 
@@ -10292,7 +10410,7 @@ _deploy_try_execute() {
10292
10410
  rsha="$(python3 -c 'import json,sys;print((json.load(open(sys.argv[1])).get("verification") or {}).get("hash") or "")' "$pj" 2>/dev/null || printf '')"
10293
10411
  rec="$(_deploy_record "$dir" "$exec_cmd" "$rc" "${run_id:-unknown}" "${anchor:-unknown}" \
10294
10412
  "$anchor_state" "$health_status" "$health_url" "$rb_attempted" "$rb_exit" \
10295
- "$rsha" "$started_at" "$finished_at" "${command%% *}" "$canary" "$post_rb" "$hcode" "$health_match" "$(git -C "$dir" rev-parse 'HEAD^{tree}' 2>/dev/null || true)")"
10413
+ "$rsha" "$started_at" "$finished_at" "${command%% *}" "$canary" "$post_rb" "$hcode" "$health_match" "$(git -C "$dir" rev-parse 'HEAD^{tree}' 2>/dev/null || true)" "$dest")"
10296
10414
  echo ""
10297
10415
  if [ "$rc" -eq 0 ]; then
10298
10416
  echo -e "${GREEN}Deploy command exited 0.${NC}"
@@ -10403,6 +10521,12 @@ _deploy_gate() {
10403
10521
  esac
10404
10522
  fi
10405
10523
 
10524
+ if [ "${_LOKI_DEPLOY_DESTINATION:-}" = production ]; then
10525
+ _deploy_staging_receipt_ok "$dir" "$(git -C "$dir" rev-parse HEAD 2>/dev/null || true)" \
10526
+ || reasons="${reasons}no VERIFIED staging deploy receipt for this SHA (deploy with --destination staging first)
10527
+ "
10528
+ fi
10529
+
10406
10530
  local dirty
10407
10531
  dirty="$(_deploy_tree_clean "$dir")"
10408
10532
  [ -n "$dirty" ] && reasons="${reasons}${dirty}
@@ -10434,7 +10558,7 @@ cmd_deploy() {
10434
10558
  # file alone weakens this gate.
10435
10559
  local LOKI_DEPLOY_ALLOW_UNANCHORED=false
10436
10560
  # Per-invocation like --execute; local so an ambient env var cannot set them.
10437
- local _LOKI_DEPLOY_HEALTH_URL="" _LOKI_DEPLOY_HEALTH_MATCH="" _LOKI_DEPLOY_AUTO_ROLLBACK=false _LOKI_DEPLOY_CANARY=false
10561
+ local _LOKI_DEPLOY_HEALTH_URL="" _LOKI_DEPLOY_HEALTH_MATCH="" _LOKI_DEPLOY_AUTO_ROLLBACK=false _LOKI_DEPLOY_CANARY=false _LOKI_DEPLOY_DESTINATION=""
10438
10562
 
10439
10563
  # Arg parse (mirror cmd_preview: lenient on unknown args -> no behavior drift).
10440
10564
  while [ $# -gt 0 ]; do
@@ -10442,7 +10566,7 @@ cmd_deploy() {
10442
10566
  --help|-h|help)
10443
10567
  echo -e "${BOLD}Loki Mode -- deploy command advisory (print-only by default)${NC}"
10444
10568
  echo ""
10445
- echo "Usage: loki deploy [--dir <path>] [--no-clip] [--execute [--canary]] [--health-url URL [--health-match STR] [--auto-rollback]]"
10569
+ echo "Usage: loki deploy [--dir <path>] [--no-clip] [--execute [--canary]] [--destination preview|staging|production] [--health-url URL [--health-match STR] [--auto-rollback]]"
10446
10570
  echo ""
10447
10571
  echo "Detects your project type and your installed cloud CLI (and any"
10448
10572
  echo "CI/CD pipeline), then PRINTS the exact deploy command for YOU to run."
@@ -10528,6 +10652,10 @@ cmd_deploy() {
10528
10652
  echo " --dir <path> Project directory to scan (default: current dir)"
10529
10653
  echo " --no-clip Do not copy the idiomatic command to the clipboard"
10530
10654
  echo " --execute Run the deploy command if the receipt gate passes"
10655
+ echo " --destination preview|staging|production"
10656
+ echo " With --execute: preview/staging run a non-production deploy"
10657
+ echo " (Vercel only); production is refused unless a VERIFIED staging"
10658
+ echo " deploy record exists for the same SHA. Unset keeps today's behavior."
10531
10659
  echo " --allow-unanchored"
10532
10660
  echo " Proceed when the receipt records NO baseline (nothing"
10533
10661
  echo " about this tree is proven). Never bypasses a recorded"
@@ -10559,6 +10687,12 @@ cmd_deploy() {
10559
10687
  --canary)
10560
10688
  _LOKI_DEPLOY_CANARY=true
10561
10689
  ;;
10690
+ --destination)
10691
+ case "${2:-}" in
10692
+ preview|staging|production) _LOKI_DEPLOY_DESTINATION="$2"; shift ;;
10693
+ *) echo "loki deploy --destination must be one of: preview, staging, production" >&2; return 1 ;;
10694
+ esac
10695
+ ;;
10562
10696
  --dir)
10563
10697
  # Guard against a missing OR flag-shaped value: an unguarded
10564
10698
  # shift when --dir is the last arg underflows under set -e, and a
@@ -10588,6 +10722,10 @@ cmd_deploy() {
10588
10722
  echo "loki deploy --canary requires --execute" >&2
10589
10723
  return 1
10590
10724
  fi
10725
+ if [ "$_LOKI_DEPLOY_CANARY" = true ] && [ -n "$_LOKI_DEPLOY_DESTINATION" ] && [ "$_LOKI_DEPLOY_DESTINATION" != production ]; then
10726
+ echo "loki deploy --canary only applies to --destination production" >&2
10727
+ return 1
10728
+ fi
10591
10729
  if [ "$do_execute" != true ] && [ -n "$_LOKI_DEPLOY_HEALTH_URL" ]; then
10592
10730
  echo "Note: --health-url and --auto-rollback only apply with --execute; ignored on this advisory run." >&2
10593
10731
  fi
@@ -14630,6 +14768,7 @@ cmd_doctor() {
14630
14768
  echo "loki doctor: the Bun doctor is unavailable, so only this minimal report can run."
14631
14769
  echo "Fix: ${_LOKI_DELEGATE_FIX}"
14632
14770
  echo "${_LOKI_DELEGATE_WHY}"
14771
+ ( source "$SKILL_DIR/providers/model_routes.sh" 2>/dev/null && loki_model_route_lane_report ) || true
14633
14772
  fi
14634
14773
  return 1
14635
14774
  }
@@ -18632,6 +18771,7 @@ cmd_modernize() {
18632
18771
  echo " heal <path> Legacy system healing in phases (was: loki heal)"
18633
18772
  echo " migrate <path> Codebase migration in phases (was: loki migrate)"
18634
18773
  echo " unit --repo <owner/name> --ref <ref> --unit <id> Run one unit (remote unit job worker)"
18774
+ echo " <repo> --to <lang> Full modernization: oracle capture, waves, verify, ship"
18635
18775
  echo ""
18636
18776
  echo "Run 'loki modernize <subcommand> --help' for each. The old"
18637
18777
  echo "top-level names still work; see 'loki help aliases'."
@@ -21646,6 +21786,9 @@ main() {
21646
21786
  steer)
21647
21787
  cmd_steer "$@"
21648
21788
  ;;
21789
+ cancel)
21790
+ cmd_cancel "$@"
21791
+ ;;
21649
21792
  resume)
21650
21793
  cmd_resume "$@"
21651
21794
  ;;
@@ -21787,6 +21930,12 @@ main() {
21787
21930
  rollback)
21788
21931
  cmd_rollback "$@"
21789
21932
  ;;
21933
+ backup)
21934
+ cmd_backup "$@"
21935
+ ;;
21936
+ restore)
21937
+ cmd_restore "$@"
21938
+ ;;
21790
21939
  merge)
21791
21940
  cmd_merge "$@"
21792
21941
  ;;
@@ -26327,6 +26476,178 @@ cmd_merge() {
26327
26476
  # Restore is destructive on .loki/ state, so it ALWAYS captures a forced
26328
26477
  # pre-rollback snapshot first (re-undoability invariant) and then glob-restores
26329
26478
  # whatever the checkpoint dir contains (works across all three checkpoint writers).
26479
+ # P12-48: back up and restore the machine-global dashboard state directory
26480
+ # (audit log, retention checkpoint, config). Archive = tar.gz holding
26481
+ # LOKI-BACKUP-MANIFEST (sha256 per file) plus the state tree, with a sidecar
26482
+ # <archive>.sha256 over the whole archive. Restore verifies both before
26483
+ # touching anything and refuses to overwrite existing state without --force.
26484
+ _backup_state_dir() {
26485
+ printf '%s' "${LOKI_DASHBOARD_STATE_DIR:-${HOME}/.loki/dashboard}"
26486
+ }
26487
+
26488
+ _backup_sha256() {
26489
+ if command -v sha256sum >/dev/null 2>&1; then
26490
+ sha256sum "$1" | awk '{print $1}'
26491
+ else
26492
+ shasum -a 256 "$1" | awk '{print $1}'
26493
+ fi
26494
+ }
26495
+
26496
+ cmd_backup() {
26497
+ local dir out="" arg
26498
+ dir="$(_backup_state_dir)"
26499
+ while [ $# -gt 0 ]; do
26500
+ arg="$1"; shift
26501
+ case "$arg" in
26502
+ --dir) dir="${1:-}"; shift || true ;;
26503
+ --out) out="${1:-}"; shift || true ;;
26504
+ -h|--help)
26505
+ echo "Usage: loki backup [--dir STATE_DIR] [--out ARCHIVE.tar.gz]"
26506
+ echo " Archives the dashboard state directory (default ~/.loki/dashboard) with a"
26507
+ echo " sha256 manifest and writes ARCHIVE.sha256 next to it."
26508
+ return 0 ;;
26509
+ *) echo -e "${RED}Unknown option: $arg${NC}" >&2; return 2 ;;
26510
+ esac
26511
+ done
26512
+ if [ ! -d "$dir" ]; then
26513
+ echo -e "${RED}Error: state directory not found: $dir${NC}" >&2
26514
+ return 1
26515
+ fi
26516
+ [ -n "$out" ] || out="./loki-backup-$(date -u +%Y%m%d-%H%M%S).tar.gz"
26517
+ if [ -e "$out" ]; then
26518
+ echo -e "${RED}Error: refusing to overwrite existing archive: $out${NC}" >&2
26519
+ return 1
26520
+ fi
26521
+ dir="$(cd "$dir" && pwd -P)" || return 1
26522
+
26523
+ local mdir manifest rel digest count=0
26524
+ mdir="$(mktemp -d "${TMPDIR:-/tmp}/loki-backup.XXXXXXXX")" || return 1
26525
+ manifest="$mdir/LOKI-BACKUP-MANIFEST"
26526
+ : > "$manifest"
26527
+ while IFS= read -r -d '' rel; do
26528
+ digest="$(_backup_sha256 "$dir/$rel")" || { rm -rf -- "$mdir"; return 1; }
26529
+ printf '%s %s\n' "$digest" "$rel" >> "$manifest"
26530
+ count=$((count + 1))
26531
+ done < <(cd "$dir" && find . -type f ! -name '*.pid' -print0 | LC_ALL=C sort -z)
26532
+
26533
+ # Symlinks and special files cannot be verified by the manifest; refuse them.
26534
+ if [ -n "$(cd "$dir" && find . ! -type f ! -type d ! -name '*.pid' -print -quit)" ]; then
26535
+ rm -rf -- "$mdir"
26536
+ echo -e "${RED}Error: $dir holds symlinks or special files; cannot back up safely${NC}" >&2
26537
+ return 1
26538
+ fi
26539
+ if ! tar -czf "$out" --exclude='*.pid' -C "$mdir" LOKI-BACKUP-MANIFEST -C "$dir" .; then
26540
+ rm -f -- "$out"
26541
+ rm -rf -- "$mdir"
26542
+ echo -e "${RED}Error: tar failed${NC}" >&2
26543
+ return 1
26544
+ fi
26545
+ rm -rf -- "$mdir"
26546
+ printf '%s %s\n' "$(_backup_sha256 "$out")" "$(basename "$out")" > "${out}.sha256"
26547
+ echo "Backed up $count file(s) from $dir"
26548
+ echo " archive: $out"
26549
+ echo " sha256: ${out}.sha256"
26550
+ }
26551
+
26552
+ cmd_restore() {
26553
+ local archive="" dir force=0 expect="" arg
26554
+ dir="$(_backup_state_dir)"
26555
+ while [ $# -gt 0 ]; do
26556
+ arg="$1"; shift
26557
+ case "$arg" in
26558
+ --dir) dir="${1:-}"; shift || true ;;
26559
+ --force) force=1 ;;
26560
+ --sha256) expect="${1:-}"; shift || true ;;
26561
+ -h|--help)
26562
+ echo "Usage: loki restore ARCHIVE.tar.gz [--dir STATE_DIR] [--force] [--sha256 HEX]"
26563
+ echo " Verifies the archive (sidecar or --sha256) and its manifest, then restores."
26564
+ echo " Refuses to replace a non-empty STATE_DIR without --force; with --force the"
26565
+ echo " existing directory is moved to STATE_DIR.pre-restore.<timestamp>."
26566
+ return 0 ;;
26567
+ -*) echo -e "${RED}Unknown option: $arg${NC}" >&2; return 2 ;;
26568
+ *) archive="$arg" ;;
26569
+ esac
26570
+ done
26571
+ if [ -z "$archive" ] || [ ! -f "$archive" ]; then
26572
+ echo -e "${RED}Error: archive not found. Usage: loki restore ARCHIVE.tar.gz${NC}" >&2
26573
+ return 2
26574
+ fi
26575
+
26576
+ # 1. Whole-archive digest: explicit --sha256, else the sidecar. Required.
26577
+ if [ -z "$expect" ]; then
26578
+ if [ -f "${archive}.sha256" ]; then
26579
+ expect="$(awk 'NR==1 {print $1}' "${archive}.sha256")"
26580
+ else
26581
+ echo -e "${RED}Error: no ${archive}.sha256 sidecar and no --sha256; refusing unverified restore${NC}" >&2
26582
+ return 1
26583
+ fi
26584
+ fi
26585
+ if [ "$(_backup_sha256 "$archive")" != "$expect" ]; then
26586
+ echo -e "${RED}Error: archive sha256 mismatch (tampered or corrupt); nothing restored${NC}" >&2
26587
+ return 1
26588
+ fi
26589
+
26590
+ # 2. Entry names must be plain relative paths with no symlinks or specials.
26591
+ local listing
26592
+ listing="$(tar -tzf "$archive" 2>/dev/null)" || { echo -e "${RED}Error: unreadable archive${NC}" >&2; return 1; }
26593
+ if printf '%s\n' "$listing" | grep -E -q '(^/|(^|/)\.\.(/|$))'; then
26594
+ echo -e "${RED}Error: archive contains unsafe paths; nothing restored${NC}" >&2
26595
+ return 1
26596
+ fi
26597
+
26598
+ # 3. Extract to a staging dir beside the target (same filesystem), verify the manifest.
26599
+ mkdir -p "$(dirname "$dir")" || return 1
26600
+ local stage
26601
+ stage="$(mktemp -d "${dir}.restore.XXXXXXXX")" || return 1
26602
+ if ! tar -xzf "$archive" -C "$stage" 2>/dev/null; then
26603
+ rm -rf -- "$stage"
26604
+ echo -e "${RED}Error: extraction failed; nothing restored${NC}" >&2
26605
+ return 1
26606
+ fi
26607
+ local bad=""
26608
+ if [ ! -f "$stage/LOKI-BACKUP-MANIFEST" ]; then
26609
+ bad="missing manifest"
26610
+ elif [ -n "$(find "$stage" ! -type f ! -type d -print -quit)" ]; then
26611
+ bad="non-regular entry in archive"
26612
+ else
26613
+ local digest rel n_files n_listed
26614
+ while IFS= read -r line; do
26615
+ digest="${line%% *}"; rel="${line#* }"
26616
+ if [ ! -f "$stage/$rel" ] || [ "$(_backup_sha256 "$stage/$rel")" != "$digest" ]; then
26617
+ bad="manifest mismatch: $rel"; break
26618
+ fi
26619
+ done < "$stage/LOKI-BACKUP-MANIFEST"
26620
+ if [ -z "$bad" ]; then
26621
+ n_files="$(find "$stage" -type f ! -name LOKI-BACKUP-MANIFEST | wc -l | tr -d ' ')"
26622
+ n_listed="$(wc -l < "$stage/LOKI-BACKUP-MANIFEST" | tr -d ' ')"
26623
+ [ "$n_files" = "$n_listed" ] || bad="archive holds files the manifest does not list"
26624
+ fi
26625
+ fi
26626
+ if [ -n "$bad" ]; then
26627
+ rm -rf -- "$stage"
26628
+ echo -e "${RED}Error: archive failed verification ($bad); nothing restored${NC}" >&2
26629
+ return 1
26630
+ fi
26631
+ rm -f -- "$stage/LOKI-BACKUP-MANIFEST"
26632
+
26633
+ # 4. Never overwrite existing state silently.
26634
+ if [ -e "$dir" ] && [ -n "$(ls -A "$dir" 2>/dev/null)" ]; then
26635
+ if [ "$force" -ne 1 ]; then
26636
+ rm -rf -- "$stage"
26637
+ echo -e "${RED}Error: $dir is not empty; re-run with --force to replace it${NC}" >&2
26638
+ return 1
26639
+ fi
26640
+ local aside
26641
+ aside="${dir}.pre-restore.$(date -u +%Y%m%d-%H%M%S)"
26642
+ mv "$dir" "$aside" || { rm -rf -- "$stage"; return 1; }
26643
+ echo " previous state kept at: $aside"
26644
+ elif [ -d "$dir" ]; then
26645
+ rmdir "$dir" 2>/dev/null || true
26646
+ fi
26647
+ mv "$stage" "$dir" || return 1
26648
+ echo "Restored $dir from $archive (verified)"
26649
+ }
26650
+
26330
26651
  cmd_rollback() {
26331
26652
  local subcommand="${1:-help}"
26332
26653
  shift 2>/dev/null || true
@@ -32374,7 +32695,32 @@ _docs_invoke_provider() {
32374
32695
  # not the workload that needs the top tier. Pinned to the same
32375
32696
  # default the review subcall uses (run.sh:13762), and overridable
32376
32697
  # for anyone who wants a different trade.
32377
- result=$($t_prefix env CAVEMAN_DEFAULT_MODE=off claude -p --model "${LOKI_DOCS_MODEL:-sonnet}" "$prompt" 2>/dev/null) || exit_code=$?
32698
+ # S6 lean child: text-only role, so the lean flag set (from
32699
+ # claude-flags.sh, lazily sourced) drops user hooks, MCP and tools.
32700
+ # When the child ledger helper exists the call uses
32701
+ # --output-format json so usage is recorded, then unwraps .result.
32702
+ local _lean=() _lf _ledger=false
32703
+ if ! declare -F loki_lean_child_flags >/dev/null 2>&1 \
32704
+ && [ -f "${_LOKI_SCRIPT_DIR:-.}/lib/claude-flags.sh" ]; then
32705
+ # shellcheck source=autonomy/lib/claude-flags.sh
32706
+ . "${_LOKI_SCRIPT_DIR}/lib/claude-flags.sh" 2>/dev/null || true
32707
+ fi
32708
+ if declare -F loki_lean_child_flags >/dev/null 2>&1; then
32709
+ while IFS= read -r _lf; do _lean+=("$_lf"); done < <(loki_lean_child_flags docs 2>/dev/null)
32710
+ fi
32711
+ declare -F loki_child_ledger_append >/dev/null 2>&1 && _ledger=true
32712
+ local _fmt=()
32713
+ [ "$_ledger" = true ] && _fmt=(--output-format json)
32714
+ result=$($t_prefix env CAVEMAN_DEFAULT_MODE=off claude -p ${_lean[@]+"${_lean[@]}"} ${_fmt[@]+"${_fmt[@]}"} --model "${LOKI_DOCS_MODEL:-sonnet}" "$prompt" 2>/dev/null) || exit_code=$?
32715
+ if [ "$_ledger" = true ] && [ -n "$result" ]; then
32716
+ loki_child_ledger_append docs "$result" >/dev/null 2>&1 || true
32717
+ result=$(printf '%s' "$result" | python3 -c 'import json,sys
32718
+ try:
32719
+ d = json.load(sys.stdin)
32720
+ print(d.get("result", ""))
32721
+ except Exception:
32722
+ pass' 2>/dev/null)
32723
+ fi
32378
32724
  ;;
32379
32725
  codex)
32380
32726
  result=$($t_prefix codex exec --sandbox workspace-write "$prompt" 2>/dev/null) || exit_code=$?
@@ -32442,14 +32788,20 @@ MANIFEST_EOF
32442
32788
  # --- loki docs generate ---
32443
32789
  _docs_generate() {
32444
32790
  local target_path="."
32791
+ local docs_requested=false
32445
32792
 
32446
32793
  while [[ $# -gt 0 ]]; do
32447
32794
  case "$1" in
32448
32795
  --help|-h)
32449
- echo "Usage: loki docs generate [path]"
32796
+ echo "Usage: loki docs generate [--docs] [path]"
32450
32797
  echo "Generate full documentation suite in .loki/docs/"
32798
+ echo " --docs Generate even when LOKI_DOCS_TIER is small (explicit request)"
32451
32799
  return 0
32452
32800
  ;;
32801
+ --docs)
32802
+ docs_requested=true
32803
+ shift
32804
+ ;;
32453
32805
  -*)
32454
32806
  log_error "Unknown option: $1"
32455
32807
  return 1
@@ -32467,6 +32819,17 @@ _docs_generate() {
32467
32819
  fi
32468
32820
  target_path="$(cd "$target_path" && pwd)"
32469
32821
 
32822
+ # S6: a small-tier run (caller exports LOKI_DOCS_TIER) does not pay for the
32823
+ # eight-doc suite unless docs were asked for explicitly.
32824
+ case "${LOKI_DOCS_TIER:-}" in
32825
+ small|simple)
32826
+ if [ "$docs_requested" != true ]; then
32827
+ log_info "Small tier: skipping the 8-doc suite (pass --docs to force)"
32828
+ return 0
32829
+ fi
32830
+ ;;
32831
+ esac
32832
+
32470
32833
  local docs_dir="$target_path/.loki/docs"
32471
32834
  mkdir -p "$docs_dir"
32472
32835