loki-mode 11.12.1 → 11.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +2 -2
  2. package/SKILL.md +3 -3
  3. package/VERSION +1 -1
  4. package/autonomy/grill.sh +3 -1
  5. package/autonomy/hooks/validate-bash.sh +51 -13
  6. package/autonomy/lib/backlog.py +18 -3
  7. package/autonomy/lib/chat.sh +117 -7
  8. package/autonomy/lib/claude-flags.sh +20 -0
  9. package/autonomy/lib/claude_permissions.py +40 -0
  10. package/autonomy/lib/done-recognition.sh +8 -4
  11. package/autonomy/lib/engine10-push.sh +56 -7
  12. package/autonomy/lib/prd-enrich.sh +4 -2
  13. package/autonomy/lib/proof-generator.py +21 -0
  14. package/autonomy/lib/proof-verify.py +109 -1
  15. package/autonomy/lib/trust_trajectory.py +139 -0
  16. package/autonomy/lib/voter-agents.sh +3 -1
  17. package/autonomy/lib/workspace.py +2 -3
  18. package/autonomy/loki +427 -74
  19. package/autonomy/quickstart.sh +21 -2
  20. package/autonomy/run.sh +55 -21
  21. package/autonomy/trigger-server.py +461 -32
  22. package/bin/loki +4 -1
  23. package/completions/_loki +1 -1
  24. package/completions/loki.bash +10 -2
  25. package/dashboard/__init__.py +1 -1
  26. package/dashboard/api_start.py +2 -2
  27. package/dashboard/build_supervisor.py +2 -0
  28. package/docs/CLI-REFERENCE.md +6 -4
  29. package/docs/DEPLOYMENT.md +12 -5
  30. package/docs/EVALUATING.md +13 -0
  31. package/docs/INSTALLATION.md +2 -2
  32. package/docs/SIGNED-RECEIPTS.md +7 -0
  33. package/docs/WORKSPACES.md +2 -2
  34. package/docs/cost-controls.md +26 -0
  35. package/docs/merge-queue.md +19 -0
  36. package/docs/sentry-intake.md +4 -0
  37. package/docs/slack.md +2 -1
  38. package/docs/trackers.md +12 -1
  39. package/docs/v10/BOARD.md +148 -123
  40. package/docs/v10/CLI-MODERN.md +5 -5
  41. package/docs/v10/DECISIONS.md +2 -0
  42. package/docs/v10/ENGINE.md +2 -0
  43. package/docs/v10/FAILURE-CLASSES.md +18 -0
  44. package/docs/v10/MODERNIZE.md +32 -1
  45. package/docs/v10/PROGRESS.md +7 -0
  46. package/loki-ts/data/finding-schema.json +2 -1
  47. package/loki-ts/dist/loki.js +1222 -1128
  48. package/magic/core/debate.py +27 -1
  49. package/mcp/__init__.py +1 -1
  50. package/package.json +2 -2
  51. package/packages/control-plane/dist/server.js +5895 -5292
  52. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  53. package/providers/claude.sh +6 -3
  54. package/providers/model_catalog.json +6 -0
  55. package/schemas/loki-yaml.schema.json +5 -0
  56. package/src/policies/claude_permissions.js +114 -0
  57. package/web-app/dist/assets/{AdminPage-DuT74pf5.js → AdminPage-OVzN6eLm.js} +1 -1
  58. package/web-app/dist/assets/{Avatar-BtgidR5a.js → Avatar-hCCrxrCZ.js} +1 -1
  59. package/web-app/dist/assets/{Badge-D2nt8G_I.js → Badge-CDcpkhbJ.js} +1 -1
  60. package/web-app/dist/assets/{Button-BKl2jOyb.js → Button-8mwWlhEu.js} +1 -1
  61. package/web-app/dist/assets/{CockpitPage-DaWr0EUX.js → CockpitPage-D8cf3u22.js} +1 -1
  62. package/web-app/dist/assets/{ComparePage-aIiNhxLr.js → ComparePage-CIgkbDRY.js} +1 -1
  63. package/web-app/dist/assets/{ErrorBoundary-C1Q7rV23.js → ErrorBoundary-99kJH9g2.js} +1 -1
  64. package/web-app/dist/assets/{EvidenceReceiptPanel-C-C0-tOx.js → EvidenceReceiptPanel-C9fj32eS.js} +1 -1
  65. package/web-app/dist/assets/{GitHubIssuesPanel-l3IaXDag.js → GitHubIssuesPanel-De43YQ_C.js} +1 -1
  66. package/web-app/dist/assets/{GitHubPRsPanel-DweLBgr5.js → GitHubPRsPanel-7g7ZTnkx.js} +1 -1
  67. package/web-app/dist/assets/{HomePage-B_KK7AvY.js → HomePage-CcNA24ny.js} +2 -2
  68. package/web-app/dist/assets/{LoginPage-CKS-KLdH.js → LoginPage-BYjlb-IK.js} +1 -1
  69. package/web-app/dist/assets/{MagicPage-Cl6CEp8J.js → MagicPage-C0Dwt9Na.js} +1 -1
  70. package/web-app/dist/assets/{MetricsPage-n9CPUaX0.js → MetricsPage-jDGmdcgg.js} +1 -1
  71. package/web-app/dist/assets/{NotFoundPage-D85eWn70.js → NotFoundPage-C_ZO8tLE.js} +1 -1
  72. package/web-app/dist/assets/{ProjectPage-CDN7LpSo.js → ProjectPage-B5wnurfQ.js} +3 -3
  73. package/web-app/dist/assets/{ProjectsPage-D4SDmU4-.js → ProjectsPage--EyKt5-D.js} +1 -1
  74. package/web-app/dist/assets/{SettingsPage-Dby6GnKm.js → SettingsPage-cB0lnfti.js} +1 -1
  75. package/web-app/dist/assets/{ShowcasePage-CgHxeqoU.js → ShowcasePage-Dl16FDkK.js} +1 -1
  76. package/web-app/dist/assets/{SystemSettingsPage-BaewGHlc.js → SystemSettingsPage-BmAXd4V2.js} +1 -1
  77. package/web-app/dist/assets/{TeamsPage-905MJyke.js → TeamsPage-BFkWVOZe.js} +1 -1
  78. package/web-app/dist/assets/{TemplatesPage-DMrUHcvF.js → TemplatesPage-Bf2kmEUM.js} +1 -1
  79. package/web-app/dist/assets/{TerminalOutput-BsbEbBYX.js → TerminalOutput-BItv9Wa6.js} +1 -1
  80. package/web-app/dist/assets/{activity-BuaXhBsC.js → activity-Df2Awg6I.js} +1 -1
  81. package/web-app/dist/assets/{bell-BfEQ71OG.js → bell-D-1ztTsJ.js} +1 -1
  82. package/web-app/dist/assets/{bot-DOGeYewg.js → bot-A4qgZsKi.js} +1 -1
  83. package/web-app/dist/assets/{check-zbjPpb3E.js → check-BPfFpF0c.js} +1 -1
  84. package/web-app/dist/assets/{chevron-left-DWTOgfFt.js → chevron-left-CfSJvmDi.js} +1 -1
  85. package/web-app/dist/assets/{circle-alert-BEPKfRzR.js → circle-alert-wx2d1dzb.js} +1 -1
  86. package/web-app/dist/assets/{clock-C_fX3OHo.js → clock-pQMai6l-.js} +1 -1
  87. package/web-app/dist/assets/{cloud-DUXHygXQ.js → cloud-DWL470r6.js} +1 -1
  88. package/web-app/dist/assets/{code-xml-BEVl_9Hw.js → code-xml-BaEQpQLZ.js} +1 -1
  89. package/web-app/dist/assets/{database-_a22uh7l.js → database-h-N7WkQN.js} +1 -1
  90. package/web-app/dist/assets/{dollar-sign-l2en7xfg.js → dollar-sign-B5oYiD27.js} +1 -1
  91. package/web-app/dist/assets/{file-code-corner-DItKl6H4.js → file-code-corner-fbTtTy0o.js} +1 -1
  92. package/web-app/dist/assets/{file-plus-BSk7qFMA.js → file-plus-PaF6HMo8.js} +1 -1
  93. package/web-app/dist/assets/{globe-2TveFE5l.js → globe-Ct-GlWID.js} +1 -1
  94. package/web-app/dist/assets/{hammer-BU6OJlQj.js → hammer-C1oZFX51.js} +1 -1
  95. package/web-app/dist/assets/{index-DGnZXUYk.js → index-C2egrRhz.js} +3 -3
  96. package/web-app/dist/assets/{layers-DKw7MNuq.js → layers-Dt5ZK4v2.js} +1 -1
  97. package/web-app/dist/assets/{loader-circle-CoYfykzD.js → loader-circle-DrpMziic.js} +1 -1
  98. package/web-app/dist/assets/{lock-D9JzrXDL.js → lock-DOez8MmK.js} +1 -1
  99. package/web-app/dist/assets/{package-BZtqTIqH.js → package-ClGIkerr.js} +1 -1
  100. package/web-app/dist/assets/{plus-BGJfo8bV.js → plus-C004i9ua.js} +1 -1
  101. package/web-app/dist/assets/{refresh-cw-F6lowBgR.js → refresh-cw-Ddfk-7Sy.js} +1 -1
  102. package/web-app/dist/assets/{rotate-ccw-DHOK8dM4.js → rotate-ccw-Bu1KgDjS.js} +1 -1
  103. package/web-app/dist/assets/{scroll-text-CKcROoMV.js → scroll-text-3q1lW5pm.js} +1 -1
  104. package/web-app/dist/assets/{server-DbG8MDv6.js → server-pd6x4HW1.js} +1 -1
  105. package/web-app/dist/assets/{shield-alert-RNKNXdJT.js → shield-alert-B9jGJepT.js} +1 -1
  106. package/web-app/dist/assets/{thumbs-up-Yxp-UBxS.js → thumbs-up-Cdcc9nQf.js} +1 -1
  107. package/web-app/dist/assets/{trash-2-DY-eba1n.js → trash-2-foSojXHK.js} +1 -1
  108. package/web-app/dist/assets/{trending-down-CARiDB3t.js → trending-down-CTTiqlJ1.js} +1 -1
  109. package/web-app/dist/assets/{trending-up-brQqWbA3.js → trending-up-B_MgmRAx.js} +1 -1
  110. package/web-app/dist/assets/{upload-D6wRjKhz.js → upload-g6BfALzG.js} +1 -1
  111. package/web-app/dist/assets/{usePolling-BMrqNbAI.js → usePolling-DrHwvzuh.js} +1 -1
  112. package/web-app/dist/assets/{user-CK5-EXIj.js → user-COp75m7l.js} +1 -1
  113. package/web-app/dist/index.html +1 -1
  114. 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.12.1
6
+ # Loki Mode v11.17.1
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`, `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`
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`, `steer`
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.12.1 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
486
+ **v11.17.1 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 11.12.1
1
+ 11.17.1
package/autonomy/grill.sh CHANGED
@@ -245,7 +245,9 @@ grill_invoke_provider() {
245
245
  # must never mutate the tree (defense-in-depth). Deny Edit/Write/
246
246
  # NotebookEdit + git mutations. Opt out LOKI_REVIEW_TOOL_GUARD=0.
247
247
  # Type-guarded so an environment without the helper degrades cleanly.
248
- local _gr_argv=("--dangerously-skip-permissions" "--model" "${LOKI_GRILL_MODEL:-sonnet}")
248
+ _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
249
+ ! type _loki_claude_permission_argv >/dev/null 2>&1 || _loki_claude_permission_argv
250
+ local _gr_argv=("${_LOKI_PERM_ARGV[@]}" "--model" "${LOKI_GRILL_MODEL:-sonnet}")
249
251
  if type loki_subcall_bare_enabled >/dev/null 2>&1 && loki_subcall_bare_enabled; then
250
252
  _gr_argv+=("--bare")
251
253
  fi
@@ -28,8 +28,16 @@ case "${LOKI_HOST_GUARD:-0}" in
28
28
  1|true|yes|on) HOST_GUARD=true ;;
29
29
  esac
30
30
 
31
+ # P12-26d: under LOKI_PERMISSION_POLICY=compiled the compiled allow/deny list
32
+ # and permission mode decide. An explicit hook "allow" skips the permission
33
+ # prompt for every command that passes the blocklist, which would widen the
34
+ # compiled policy, so the hook never emits one and fails closed on bad input.
35
+ COMPILED_POLICY=false
36
+ [ "${LOKI_PERMISSION_POLICY:-}" = "compiled" ] && COMPILED_POLICY=true
37
+
31
38
  if ! command -v python3 >/dev/null 2>&1; then
32
39
  "$HOST_GUARD" && deny "LOKI_HOST_GUARD: python3 is unavailable, so the Bash command cannot be inspected safely."
40
+ "$COMPILED_POLICY" && deny "LOKI_PERMISSION_POLICY=compiled: python3 is unavailable, so the Bash command cannot be inspected safely."
33
41
  printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
34
42
  exit 0
35
43
  fi
@@ -46,6 +54,7 @@ if not isinstance(command, str) or not command.strip():
46
54
  sys.stdout.write(command)
47
55
  '); then
48
56
  "$HOST_GUARD" && deny "LOKI_HOST_GUARD: malformed or missing Bash command input was denied."
57
+ "$COMPILED_POLICY" && deny "LOKI_PERMISSION_POLICY=compiled: malformed or missing Bash command input was denied."
49
58
  printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
50
59
  exit 0
51
60
  fi
@@ -342,24 +351,50 @@ BLOCKED_PATTERNS=(
342
351
  ":\(\) *\{.*\|.*&"
343
352
  )
344
353
 
345
- # Safe path patterns that override blocked pattern matches
354
+ # Safe path patterns that override blocked pattern matches. They are matched
355
+ # per SEGMENT (the command split on ; & | newline CR), never against the whole
356
+ # string: an unanchored or per-line match let `rm -rf /tmp/x; rm -rf /` and
357
+ # `rm -rf /tmp/x<newline>rm -rf /` ride the override. Only the segment that hit
358
+ # a blocked pattern must be safe; an optional trailing /dev/null redirection is
359
+ # allowed, and `*` and `$$` in /tmp names. A `..` anywhere in the segment is
360
+ # never safe.
361
+ _SAFE_REDIR='([[:space:]]+[0-9]*>>?[[:space:]]*/dev/null)*[[:space:]]*'
362
+ _SAFE_ENV='([A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9._/:-]*[[:space:]]+)*'
363
+ # A dot-leading path component with a glob char (`/tmp/.*` expands to `/tmp/..`).
364
+ _DOT_GLOB='/\.[^/[:space:]]*[*?[]'
346
365
  SAFE_PATTERNS=(
347
- "rm -rf /tmp/"
348
- "rm -rf \.loki/queue/dead-letter"
366
+ '^[[:space:]]*'"$_SAFE_ENV"'rm -rf (/tmp/([A-Za-z0-9._/*-]|\$\$)+[[:space:]]*)+'"$_SAFE_REDIR"'$'
367
+ '^[[:space:]]*'"$_SAFE_ENV"'rm -rf \.loki/queue/dead-letter[A-Za-z0-9._/-]*'"$_SAFE_REDIR"'$'
349
368
  )
350
369
 
370
+ _segment_is_safe() {
371
+ local seg="$1" safe
372
+ [[ "$seg" == *".."* ]] && return 1
373
+ [[ "$seg" =~ $_DOT_GLOB ]] && return 1
374
+ for safe in "${SAFE_PATTERNS[@]}"; do
375
+ [[ "$seg" =~ $safe ]] && return 0
376
+ done
377
+ return 1
378
+ }
379
+
380
+ # Succeeds when every segment hitting $1 is safe and at least one segment hits
381
+ # (a pattern that only matches across segments, like `curl x | sh`, is unsafe).
382
+ _blocked_hits_all_safe() {
383
+ local pat="$1" flat seg hit=false
384
+ # Strip fd duplication (2>&1) first: its `&` is not a command separator, and removing it hides no command.
385
+ flat=$(printf '%s' "$COMMAND" | sed -E 's/[0-9]*>&[0-9]+//g' | tr ';&|\r' '\n')
386
+ while IFS= read -r seg; do
387
+ printf '%s' "$seg" | grep -qE "$pat" || continue
388
+ hit=true
389
+ _segment_is_safe "$seg" || return 1
390
+ done <<< "$flat"
391
+ "$hit"
392
+ }
393
+
351
394
  # Check for blocked patterns
352
395
  for pattern in "${BLOCKED_PATTERNS[@]}"; do
353
396
  if echo "$COMMAND" | grep -qE "$pattern"; then
354
- # Check if a safe pattern also matches (whitelist override)
355
- is_safe=false
356
- for safe in "${SAFE_PATTERNS[@]}"; do
357
- if echo "$COMMAND" | grep -qE "$safe"; then
358
- is_safe=true
359
- break
360
- fi
361
- done
362
- "$is_safe" && continue
397
+ _blocked_hits_all_safe "$pattern" && continue
363
398
  deny "Blocked: potentially dangerous command pattern detected"
364
399
  fi
365
400
  done
@@ -370,7 +405,10 @@ mkdir -p "$LOG_DIR"
370
405
  printf '%s' "{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"command\":$(echo "$COMMAND" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))')}" >> "$LOG_DIR/bash-audit.jsonl"
371
406
  echo >> "$LOG_DIR/bash-audit.jsonl"
372
407
 
373
- # Allow command
408
+ # Allow command. Compiled policy: no decision, the permission flow decides.
409
+ if "$COMPILED_POLICY"; then
410
+ exit 0
411
+ fi
374
412
  printf '%s' '{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}'
375
413
 
376
414
  exit 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:
@@ -3,7 +3,7 @@
3
3
  # Sourced by autonomy/loki; defines cmd_chat. Free text goes to the provider
4
4
  # in the current mode; slash commands delegate to existing loki commands.
5
5
 
6
- _LOKI_CHAT_SYSTEM_PROMPT="You are running inside Loki Mode chat. Be concise. Prefer small, verifiable changes. Loki commands available to the user: loki start (also --workspace and --deploy), loki quick, loki verify, loki receipt, loki undo."
6
+ _LOKI_CHAT_SYSTEM_PROMPT="You are running inside Loki Mode chat. Be concise. Prefer small, verifiable changes. Loki commands available to the user: loki start (also --workspace and --deploy), loki quick, loki verify, loki receipt, loki undo. During a run, /steer sends a mid-run directive via loki steer (applied at the next plan or implement stage); /pause and /resume control the run, and /answer replies to a blocked run."
7
7
 
8
8
  _chat_loki_bin() {
9
9
  local d="${_LOKI_SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
@@ -41,7 +41,14 @@ _chat_help() {
41
41
  Loki chat commands:
42
42
  /plan read-only mode (no edits)
43
43
  /build edit mode
44
- /run <prd|brief> loki start with this session's model and provider (same run as the shell)
44
+ /run <prd|brief> loki start with this session's model and provider, in the background (alias /start)
45
+ /start <prd|brief> alias of /run
46
+ /status loki status
47
+ /steer <msg> send a mid-run directive via loki steer (shows the "Steer rev N received" ack)
48
+ /stop loki stop
49
+ /pause loki pause (the run halts at the next iteration boundary)
50
+ /resume loki resume
51
+ /answer <text> loki answer --text: answer a BLOCKED run with a follow-up run
45
52
  /workspace <name> <brief> loki start --workspace (multi-repo run, composite receipt)
46
53
  /deploy [--health-url U] [--auto-rollback] loki deploy --execute after you confirm (gated on a VERIFIED receipt)
47
54
  /attach attach to the current or most recent loki start run in this repo
@@ -52,7 +59,9 @@ Loki chat commands:
52
59
  /model <id> set model for this session
53
60
  /provider <name> switch provider (claude, codex, cline, aider, opencode)
54
61
  /cost loki cost
62
+ /merge <args> loki merge (pr <pr>, watch <pr> to repair red CI, run, list)
55
63
  /diff git diff --stat plus diff
64
+ /doctor [--json] loki doctor --repo: repo readiness score and fixes (alias /ready)
56
65
  /undo [args] loki undo
57
66
  /help, /exit
58
67
  Attached mode (loki chat --attach or /attach): /status /steer <msg> /diff /verify /receipt /stop
@@ -62,6 +71,98 @@ HLP
62
71
  }
63
72
 
64
73
 
74
+ # /steer: record source=chat and the chat user as actor (D02). Echo loki steer's own
75
+ # "Steer rev N received" ack; never claim receipt it did not print.
76
+ _chat_steer() {
77
+ local loki="$1" msg="$2" out rc=0 first
78
+ case "$msg" in
79
+ ""|help|h) echo "usage: /steer <message> (not help)"; return 0 ;;
80
+ esac
81
+ out="$(LOKI_STEER_SOURCE=chat LOKI_STEER_ACTOR="${LOKI_STEER_ACTOR:-${USER:-chat}}" "$loki" steer -- "$msg" 2>&1)" || rc=$?
82
+ [ -z "$out" ] || printf '%s\n' "$out"
83
+ if [ "$rc" -ne 0 ]; then echo "(exit $rc)"
84
+ elif ! printf '%s\n' "$out" | grep -q 'Steer rev [0-9][0-9]* received'; then
85
+ first="$(printf '%s\n' "$out" | tr -d '\000-\010\013-\037\177' | sed -n '/[^[:space:]]/{p;q;}' | cut -c1-120)"
86
+ echo "Steer not confirmed: ${first:-no confirmation from loki steer}"
87
+ fi
88
+ return 0
89
+ }
90
+
91
+ # Monotonic milliseconds and a ms sleep; both are functions so tests can inject a fake clock.
92
+ # The clock source is chosen once per launch (_CHAT_CLOCK) so every read shares one base:
93
+ # perl clock_gettime, else whole seconds from $SECONDS.
94
+ _chat_clock_pick() {
95
+ if perl -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC -e 'exit 0' >/dev/null 2>&1; then _CHAT_CLOCK=perl; else _CHAT_CLOCK=seconds; fi
96
+ }
97
+ _chat_now_ms() {
98
+ [ -n "${_CHAT_CLOCK:-}" ] || _chat_clock_pick
99
+ if [ "$_CHAT_CLOCK" = perl ]; then
100
+ perl -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC -e 'printf "%d\n", clock_gettime(CLOCK_MONOTONIC) * 1000'
101
+ else
102
+ echo $(( SECONDS * 1000 ))
103
+ fi
104
+ }
105
+ _chat_sleep_ms() {
106
+ local ms="$1"
107
+ [ "$ms" -gt 0 ] 2>/dev/null || return 0
108
+ sleep "$(( ms / 1000 )).$(printf '%03d' $(( ms % 1000 )))"
109
+ }
110
+
111
+ # /run launch: background `loki start`, then print "Run started" only after the same
112
+ # signals `loki status` reads show a live new run (a new engine10 run dir or a live pid file
113
+ # that was not live before). A start that exits first reports its real error and the log path.
114
+ # Returns non-zero when the start is not proven.
115
+ _chat_run_launch() {
116
+ local loki="$1" input="$2" root="${LOKI_DIR:-.loki}" log pid rc=0 start_ms deadline_ms now_ms rem_ms iter=0 max_iter before_dir before_state
117
+ log="$root/chat-run.log"
118
+ mkdir -p "$root"
119
+ _chat_clock_pick
120
+ start_ms="$(_chat_now_ms)" || start_ms=""
121
+ case "$start_ms" in
122
+ ''|*[!0-9]*)
123
+ echo "Run not started (clock read failed before launch). Retry /run." >&2
124
+ return 1
125
+ ;;
126
+ esac
127
+ before_dir="$(_chat_run_dir)"; before_state="$(_chat_run_state "$before_dir")"
128
+ _chat_run_start "$loki" "$input" < /dev/null > "$log" 2>&1 &
129
+ pid=$!
130
+ local wait_s="${LOKI_CHAT_START_WAIT:-15}"
131
+ case "$wait_s" in ''|*[!0-9]*) wait_s=15 ;; esac
132
+ [ "$wait_s" -ge 1 ] || wait_s=1
133
+ deadline_ms=$(( start_ms + wait_s * 1000 ))
134
+ # Whole-second clock floors, so allow 1s of slack rather than give up early.
135
+ [ "$_CHAT_CLOCK" = perl ] || deadline_ms=$(( deadline_ms + 1000 ))
136
+ max_iter=$(( wait_s * 5 + 10 ))
137
+ while :; do
138
+ iter=$(( iter + 1 ))
139
+ local nd ns
140
+ nd="$(_chat_run_dir)"; ns="$(_chat_run_state "$nd")"
141
+ if [ "$ns" = "running" ] && { { [ "$nd" != "$before_dir" ] && _chat_pidfile_live "$nd/run.pid"; } || { [ "$nd" = "$before_dir" ] && [ "$before_state" != "running" ]; }; }; then
142
+ echo "Run started in the background (pid $pid). Output: $log"
143
+ return 0
144
+ fi
145
+ if ! kill -0 "$pid" 2>/dev/null; then
146
+ wait "$pid" 2>/dev/null || true
147
+ rc="$(sed -n 's/.*(run finished, exit \([0-9][0-9]*\).*/\1/p' "$log" 2>/dev/null | tail -n1)"
148
+ case "$rc" in ''|0) rc=1 ;; esac
149
+ echo "Run did not start (loki start exited $rc before a live run appeared). Log: $log" >&2
150
+ tail -n 5 "$log" 2>/dev/null | cut -c1-200 >&2
151
+ return "$rc"
152
+ fi
153
+ # A failed or non-numeric clock read (perl fork EAGAIN) counts as the deadline: never mix clock bases.
154
+ now_ms="$(_chat_now_ms)" || break
155
+ case "$now_ms" in ''|*[!0-9]*) break ;; esac
156
+ rem_ms=$(( deadline_ms - now_ms ))
157
+ [ "$rem_ms" -gt 0 ] || break
158
+ [ "$iter" -lt "$max_iter" ] || break
159
+ [ "$rem_ms" -le 200 ] || rem_ms=200
160
+ _chat_sleep_ms "$rem_ms"
161
+ done
162
+ echo "Run start not confirmed after ${wait_s}s (no live run state; launcher pid $pid still running). Log: $log" >&2
163
+ return 1
164
+ }
165
+
65
166
  # Run a delegate without letting a non-zero exit end the session (autonomy/loki runs under set -e).
66
167
  _chat_do() { local _rc=0; "$@" || _rc=$?; [ "$_rc" -eq 0 ] || echo "(exit $_rc)"; return 0; }
67
168
 
@@ -316,7 +417,7 @@ Question: $line" || true
316
417
  /status) _chat_do "$loki" status; continue ;;
317
418
  /steer)
318
419
  [ -n "$rest" ] || { echo "usage: /steer <message>"; continue; }
319
- _chat_do "$loki" steer "$rest"; continue ;;
420
+ _chat_steer "$loki" "$rest"; continue ;;
320
421
  /stop) _chat_do "$loki" stop; continue ;;
321
422
  /detach) attached=0; echo "Detached."; continue ;;
322
423
  esac
@@ -326,10 +427,10 @@ Question: $line" || true
326
427
  /help) _chat_help ;;
327
428
  /plan) mode="plan"; echo "Plan mode: read-only." ;;
328
429
  /build) mode="build"; echo "Build mode: edits allowed." ;;
329
- /run)
430
+ /run|/start)
330
431
  [ -n "$rest" ] || { echo "usage: /run <prd|brief>"; continue; }
331
- _chat_run_start "$loki" "$rest"
332
- _chat_attach_banner || true ;;
432
+ local _lrc=0; _chat_run_launch "$loki" "$rest" || _lrc=$?
433
+ if [ "$_lrc" -eq 0 ]; then _chat_attach_banner || true; else echo "(exit $_lrc)"; fi ;;
333
434
  /workspace)
334
435
  local _ws_name="${rest%% *}" _ws_brief=""
335
436
  [ "$_ws_name" != "$rest" ] && _ws_brief="${rest#* }"
@@ -342,8 +443,13 @@ Question: $line" || true
342
443
  /status) _chat_do "$loki" status ;;
343
444
  /steer)
344
445
  [ -n "$rest" ] || { echo "usage: /steer <message>"; continue; }
345
- _chat_do "$loki" steer "$rest" ;;
446
+ _chat_steer "$loki" "$rest" ;;
346
447
  /stop) _chat_do "$loki" stop ;;
448
+ /pause) _chat_do "$loki" pause ;;
449
+ /resume) _chat_do "$loki" resume ;;
450
+ /answer)
451
+ [ -n "$rest" ] || { echo "usage: /answer <text>"; continue; }
452
+ _chat_do "$loki" answer --text "$rest" ;;
347
453
  /detach) echo "Not attached." ;;
348
454
  /quick)
349
455
  [ -n "$rest" ] || { echo "usage: /quick <task>"; continue; }
@@ -355,8 +461,12 @@ Question: $line" || true
355
461
  _chat_do "$loki" verify $rest ;;
356
462
  /receipt) _chat_do "$loki" receipt ;;
357
463
  /cost) _chat_do "$loki" cost ;;
464
+ /merge) # shellcheck disable=SC2086
465
+ _chat_do "$loki" merge $rest ;;
358
466
  /undo) # shellcheck disable=SC2086
359
467
  _chat_do "$loki" undo $rest ;;
468
+ /doctor|/ready) # shellcheck disable=SC2086
469
+ _chat_do "$loki" doctor --repo $rest ;;
360
470
  /diff) _chat_do git diff --stat; _chat_do git diff ;;
361
471
  /model)
362
472
  if [ -n "$rest" ]; then _CHAT_MODEL="$rest"; echo "model: $rest"; else echo "model: ${_CHAT_MODEL:-default}"; fi ;;
@@ -972,3 +972,23 @@ loki_export_subagent_capacity() {
972
972
  # subprocess tree inherits it, so every subcall site gets the same capacity
973
973
  # without each one having to remember to call this.
974
974
  loki_export_subagent_capacity
975
+
976
+ # P12-26: LOKI_PERMISSION_POLICY=compiled swaps --dangerously-skip-permissions for
977
+ # --permission-mode / --allowedTools / --disallowedTools compiled from the task policy
978
+ # by src/policies/claude_permissions.js (shared with the TS permissionArgv). Unset =
979
+ # unchanged. Result lands in _LOKI_PERM_ARGV. A compile failure fails closed to plan.
980
+ _loki_claude_permission_argv() {
981
+ _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
982
+ [ "${LOKI_PERMISSION_POLICY:-}" = "compiled" ] || return 0
983
+ local _helper _out _a
984
+ _helper="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/src/policies/claude_permissions.js"
985
+ _LOKI_PERM_ARGV=(--permission-mode plan)
986
+ if ! command -v node >/dev/null 2>&1 || [ ! -f "$_helper" ]; then
987
+ echo "[loki] WARNING: LOKI_PERMISSION_POLICY=compiled: compiler unavailable; using most restrictive mode (plan)" >&2
988
+ return 0
989
+ fi
990
+ _out="$(node "$_helper" "${LOKI_PROJECT_DIR:-$PWD}")" || return 0
991
+ [ -n "$_out" ] || return 0
992
+ _LOKI_PERM_ARGV=()
993
+ while IFS= read -r _a; do _LOKI_PERM_ARGV+=("$_a"); done <<< "$_out"
994
+ }
@@ -0,0 +1,40 @@
1
+ """P12-26b: Python entry to the shared Claude permission policy.
2
+
3
+ New file: the python launch sites (dashboard, magic) need the same argv as the
4
+ bash and TS routes; this is a thin caller of src/policies/claude_permissions.js
5
+ (the single compiler), not a second implementation. Unset LOKI_PERMISSION_POLICY
6
+ returns the legacy flag byte-identically.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ _LEGACY = ["--dangerously-skip-permissions"]
17
+ _FAIL_CLOSED = ["--permission-mode", "plan"]
18
+
19
+
20
+ def claude_permission_argv(project_dir: str | None = None, env: dict | None = None) -> list:
21
+ env = os.environ if env is None else env
22
+ if env.get("LOKI_PERMISSION_POLICY", "") != "compiled":
23
+ return list(_LEGACY)
24
+ helper = Path(__file__).resolve().parents[2] / "src" / "policies" / "claude_permissions.js"
25
+ node = shutil.which("node")
26
+ if node is None or not helper.is_file():
27
+ print("[loki] WARNING: LOKI_PERMISSION_POLICY=compiled: compiler unavailable; using most restrictive mode (plan)", file=sys.stderr)
28
+ return list(_FAIL_CLOSED)
29
+ target = project_dir or env.get("LOKI_PROJECT_DIR") or os.getcwd()
30
+ try:
31
+ done = subprocess.run([node, str(helper), str(target)], capture_output=True, text=True,
32
+ timeout=30, env=dict(env), check=False)
33
+ except (OSError, subprocess.SubprocessError):
34
+ return list(_FAIL_CLOSED)
35
+ if done.stderr:
36
+ sys.stderr.write(done.stderr)
37
+ args = [a for a in done.stdout.split("\n") if a]
38
+ if done.returncode != 0 or not args:
39
+ return list(_FAIL_CLOSED)
40
+ return args
@@ -157,13 +157,15 @@ _loki_done_recog_invoke() {
157
157
  local _dr_schema_content _dr_json _dr_obj
158
158
  _dr_schema_content="$(cat "$_dr_schema" 2>/dev/null)" || _dr_schema_content=""
159
159
  if [ -n "$_dr_schema_content" ]; then
160
+ _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
161
+ ! type _loki_claude_permission_argv >/dev/null 2>&1 || _loki_claude_permission_argv
160
162
  if command -v timeout >/dev/null 2>&1; then
161
163
  _dr_json=$(CAVEMAN_DEFAULT_MODE=off timeout "${LOKI_DONE_RECOG_TIMEOUT}" \
162
- claude --dangerously-skip-permissions -p "$prompt" \
164
+ claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" \
163
165
  --json-schema "$_dr_schema_content" --output-format json 2>/dev/null) || _dr_json=""
164
166
  else
165
167
  _dr_json=$(CAVEMAN_DEFAULT_MODE=off \
166
- claude --dangerously-skip-permissions -p "$prompt" \
168
+ claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" \
167
169
  --json-schema "$_dr_schema_content" --output-format json 2>/dev/null) || _dr_json=""
168
170
  fi
169
171
  local _dr_py
@@ -192,12 +194,14 @@ except Exception:
192
194
 
193
195
  local rc=0
194
196
  local out=""
197
+ _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
198
+ ! type _loki_claude_permission_argv >/dev/null 2>&1 || _loki_claude_permission_argv
195
199
  if command -v timeout >/dev/null 2>&1; then
196
200
  out=$(CAVEMAN_DEFAULT_MODE=off timeout "${LOKI_DONE_RECOG_TIMEOUT}" \
197
- claude --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
201
+ claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" 2>/dev/null) || rc=$?
198
202
  else
199
203
  out=$(CAVEMAN_DEFAULT_MODE=off \
200
- claude --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
204
+ claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" 2>/dev/null) || rc=$?
201
205
  fi
202
206
  [ "$rc" -ne 0 ] && return 1
203
207
  [ -z "$out" ] && return 1
@@ -2,9 +2,10 @@
2
2
  # Loki 10 engine P4 push child (ENGINE.md sections 6 and 7).
3
3
  #
4
4
  # engine10-push.sh push-pr <repo-dir> <branch> <title> <body-file> [--draft] [--base <branch>]
5
- # engine10-push.sh check-origin (exit 0 when the pinned origin is acceptable to push-pr, else the refusal; no network)
5
+ # engine10-push.sh pr-ready <repo-dir> <branch> <body-file> <pr-url> (reused open PR: replace its body, then mark it ready; <pr-url> must be the pinned-owner PR)
6
+ # engine10-push.sh check-origin (exit 0 when the pinned origin is acceptable to push-pr, else the refusal; no network)
6
7
  # engine10-push.sh comment <pr-number> <body-file>
7
- # engine10-push.sh issue-comment <issue-ref> <body-file>
8
+ # engine10-push.sh issue-comment [--update] <issue-ref> <body-file>
8
9
  # engine10-push.sh status <sha> <pending|success|failure|error> <description>
9
10
  #
10
11
  # Holds GitHub credentials, runs no LLM, reads no untrusted text. Inputs come
@@ -117,6 +118,16 @@ _e10_local_push() {
117
118
 
118
119
  _e10_gh() { _loki_with_github_tokens _loki_run_neutral "$_e10_repo" command gh "$@"; }
119
120
 
121
+ # The one open-PR lookup for push-pr reuse and pr-ready. gh cannot filter --head by owner, so a stranger's fork PR with the
122
+ # same branch name would match; select only a same-repo PR from the pinned owner. Prints the URL, or nothing.
123
+ _e10_find_pr() {
124
+ local owner="${_e10_repo%%/*}"
125
+ case "$owner" in '' | *[!A-Za-z0-9_.-]*) return 1 ;; esac
126
+ _e10_gh pr list --repo "$_e10_repo" --head "$1" --state open \
127
+ --json url,isCrossRepository,headRepositoryOwner,headRefName,state \
128
+ --jq "[.[] | select(.isCrossRepository == false and .state == \"OPEN\" and (.headRepositoryOwner.login | ascii_downcase) == \"$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]')\")] | .[0].url // empty"
129
+ }
130
+
120
131
  mode="${1:-}"
121
132
  shift || true
122
133
  case "$mode" in
@@ -157,13 +168,30 @@ case "$mode" in
157
168
  else
158
169
  _loki_trusted_push _loki_with_github_tokens "$dir" "$branch" || die "push refused or failed (rc=$?)"
159
170
  fi
160
- url="$(_e10_gh pr list --repo "$_e10_repo" --head "$branch" --state open --json url --jq '.[0].url')" \
161
- || die "gh pr list failed"
162
- if [ -z "$url" ] || [ "$url" = "null" ]; then
171
+ url="$(_e10_find_pr "$branch")" || die "gh pr list failed"
172
+ _e10_pr_state=reused
173
+ if [ -z "$url" ]; then
174
+ _e10_pr_state=created
163
175
  url="$(_e10_gh pr create --repo "$_e10_repo" --head "$branch" --title "$title" --body-file "$body" "${draft[@]}" "${base[@]}")" \
164
176
  || die "gh pr create failed"
165
177
  url="$(printf '%s\n' "$url" | tail -n 1)"
166
178
  fi
179
+ printf '%s\n' "$_e10_pr_state" # marker line before the URL: reused (open PR found) or created
180
+ printf '%s\n' "$url"
181
+ ;;
182
+ pr-ready)
183
+ # M-21 N3: a reused wave PR whose units are all PROVEN keeps its old draft state and body; fix both, same pinned origin and tokens as push-pr.
184
+ [ "$#" -eq 4 ] || die "usage: pr-ready <repo-dir> <branch> <body-file> <pr-url>"
185
+ branch="$2" body="$3" want_url="$4"
186
+ [ -f "$body" ] || die "body file not found: $body"
187
+ git check-ref-format --branch "$branch" >/dev/null 2>&1 || die "invalid branch: $branch"
188
+ case "$branch" in main | master | HEAD) die "refusing to edit a PR for default branch: $branch" ;; esac
189
+ [ -z "$_e10_local" ] || { log_info "local origin: no PR to mark ready (no-op)"; exit 0; }
190
+ url="$(_e10_find_pr "$branch")" || die "gh pr list failed"
191
+ [ -n "$url" ] || die "no open PR from $_e10_repo for branch: $branch"
192
+ [ "$url" = "$want_url" ] || die "refusing: PR $want_url is not the pinned-owner PR for $branch ($url)"
193
+ _e10_gh pr edit "$url" --repo "$_e10_repo" --body-file "$body" >/dev/null || die "gh pr edit failed"
194
+ _e10_gh pr ready "$url" --repo "$_e10_repo" >/dev/null || die "gh pr ready failed"
167
195
  printf '%s\n' "$url"
168
196
  ;;
169
197
  comment)
@@ -176,11 +204,32 @@ case "$mode" in
176
204
  issue-comment)
177
205
  # E-67: a FAILED run with no diff (or no remote) has no PR to open; comment on the issue
178
206
  # 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>"
207
+ # --update: the status projector. One comment carrying the marker below is edited in place (created when absent),
208
+ # so an issue shows live run state without a new comment per event. Only a comment written by the authenticated
209
+ # gh user counts, and the body file must carry the marker (it is built by the supervisor, never from task text).
210
+ _e10_upd=""
211
+ [ "${1:-}" != "--update" ] || { _e10_upd=1; shift; }
212
+ [ "$#" -eq 2 ] || die "usage: issue-comment [--update] <issue-ref> <body-file>"
180
213
  num="${1##*#}"
181
214
  case "$num" in '' | *[!0-9]*) die "cannot extract issue number from: $1" ;; esac
182
215
  [ -f "$2" ] || die "body file not found: $2"
183
216
  [ -z "$_e10_local" ] || { log_info "local origin: no issue to comment on (no-op)"; exit 0; }
217
+ if [ -n "$_e10_upd" ]; then
218
+ _e10_marker='<!-- loki-status -->'
219
+ grep -qF -- "$_e10_marker" "$2" || die "status body must carry $_e10_marker"
220
+ _e10_me="$(_e10_gh api user --jq .login 2>/dev/null)" || _e10_me=""
221
+ _e10_cid=""
222
+ if [ -n "$_e10_me" ]; then
223
+ _e10_cid="$(_e10_gh api "repos/$_e10_repo/issues/$num/comments" --paginate \
224
+ --jq ".[] | select(.user.login == \"$_e10_me\") | select(.body | contains(\"$_e10_marker\")) | .id" 2>/dev/null | head -n 1)" || _e10_cid=""
225
+ fi
226
+ case "$_e10_cid" in
227
+ '') _e10_gh issue comment "$num" --repo "$_e10_repo" --body-file "$2" || die "gh issue comment failed" ;;
228
+ *[!0-9]*) die "unexpected comment id" ;;
229
+ *) _e10_gh api -X PATCH "repos/$_e10_repo/issues/comments/$_e10_cid" -F "body=@$2" >/dev/null || die "gh api comment update failed" ;;
230
+ esac
231
+ exit 0
232
+ fi
184
233
  _e10_gh issue comment "$num" --repo "$_e10_repo" --body-file "$2" || die "gh issue comment failed"
185
234
  ;;
186
235
  status)
@@ -191,5 +240,5 @@ case "$mode" in
191
240
  _e10_gh api "repos/$_e10_repo/statuses/$1" -f "state=$2" -f context=loki/deep-verify \
192
241
  -f "description=$3" >/dev/null || die "gh api status failed"
193
242
  ;;
194
- *) die "usage: engine10-push.sh push-pr|comment|issue-comment|status ..." ;;
243
+ *) die "usage: engine10-push.sh push-pr|pr-ready|comment|issue-comment|status ..." ;;
195
244
  esac
@@ -86,14 +86,16 @@ _loki_prd_enrich_invoke() {
86
86
  command -v claude >/dev/null 2>&1 || return 1
87
87
  local rc=0
88
88
  local out=""
89
+ _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
90
+ ! type _loki_claude_permission_argv >/dev/null 2>&1 || _loki_claude_permission_argv
89
91
  if command -v timeout >/dev/null 2>&1; then
90
92
  out=$(CAVEMAN_DEFAULT_MODE=off timeout "${LOKI_PRD_ENRICH_TIMEOUT}" \
91
- claude --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
93
+ claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" 2>/dev/null) || rc=$?
92
94
  else
93
95
  # No coreutils timeout (e.g. bare macOS). Run without it; the model
94
96
  # call is a one-shot and the caller still tolerates failure.
95
97
  out=$(CAVEMAN_DEFAULT_MODE=off \
96
- claude --dangerously-skip-permissions -p "$prompt" 2>/dev/null) || rc=$?
98
+ claude "${_LOKI_PERM_ARGV[@]}" -p "$prompt" 2>/dev/null) || rc=$?
97
99
  fi
98
100
  [ "$rc" -ne 0 ] && return 1
99
101
  [ -z "$out" ] && return 1