loki-mode 11.15.0 → 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 (98) hide show
  1. package/SKILL.md +3 -3
  2. package/VERSION +1 -1
  3. package/autonomy/grill.sh +3 -1
  4. package/autonomy/hooks/validate-bash.sh +51 -13
  5. package/autonomy/lib/chat.sh +111 -7
  6. package/autonomy/lib/claude-flags.sh +20 -0
  7. package/autonomy/lib/claude_permissions.py +40 -0
  8. package/autonomy/lib/done-recognition.sh +8 -4
  9. package/autonomy/lib/engine10-push.sh +30 -5
  10. package/autonomy/lib/prd-enrich.sh +4 -2
  11. package/autonomy/lib/proof-generator.py +5 -0
  12. package/autonomy/lib/proof-verify.py +109 -1
  13. package/autonomy/lib/trust_trajectory.py +13 -4
  14. package/autonomy/lib/voter-agents.sh +3 -1
  15. package/autonomy/loki +175 -26
  16. package/autonomy/quickstart.sh +21 -2
  17. package/autonomy/run.sh +16 -6
  18. package/autonomy/trigger-server.py +11 -5
  19. package/completions/loki.bash +2 -2
  20. package/dashboard/__init__.py +1 -1
  21. package/dashboard/api_start.py +2 -2
  22. package/dashboard/build_supervisor.py +2 -0
  23. package/docs/CLI-REFERENCE.md +2 -1
  24. package/docs/EVALUATING.md +1 -1
  25. package/docs/INSTALLATION.md +1 -1
  26. package/docs/v10/BOARD.md +25 -0
  27. package/docs/v10/CLI-MODERN.md +3 -3
  28. package/docs/v10/DECISIONS.md +1 -0
  29. package/docs/v10/ENGINE.md +1 -1
  30. package/docs/v10/FAILURE-CLASSES.md +18 -0
  31. package/docs/v10/PROGRESS.md +4 -0
  32. package/loki-ts/data/finding-schema.json +2 -1
  33. package/loki-ts/dist/loki.js +1135 -1114
  34. package/magic/core/debate.py +27 -1
  35. package/mcp/__init__.py +1 -1
  36. package/package.json +2 -2
  37. package/packages/control-plane/dist/server.js +3761 -3454
  38. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  39. package/providers/claude.sh +6 -3
  40. package/providers/model_catalog.json +6 -0
  41. package/src/policies/claude_permissions.js +114 -0
  42. package/web-app/dist/assets/{AdminPage-bZlvH4TL.js → AdminPage-OVzN6eLm.js} +1 -1
  43. package/web-app/dist/assets/{Avatar-BZrBstQe.js → Avatar-hCCrxrCZ.js} +1 -1
  44. package/web-app/dist/assets/{Badge-Q8l8r4Vq.js → Badge-CDcpkhbJ.js} +1 -1
  45. package/web-app/dist/assets/{Button-B8Bt0A3S.js → Button-8mwWlhEu.js} +1 -1
  46. package/web-app/dist/assets/{CockpitPage-CIYExrXa.js → CockpitPage-D8cf3u22.js} +1 -1
  47. package/web-app/dist/assets/{ComparePage-CBOmHdam.js → ComparePage-CIgkbDRY.js} +1 -1
  48. package/web-app/dist/assets/{ErrorBoundary-BKwDPSnK.js → ErrorBoundary-99kJH9g2.js} +1 -1
  49. package/web-app/dist/assets/{EvidenceReceiptPanel-KqzjphWl.js → EvidenceReceiptPanel-C9fj32eS.js} +1 -1
  50. package/web-app/dist/assets/{GitHubIssuesPanel-lF3GkHRv.js → GitHubIssuesPanel-De43YQ_C.js} +1 -1
  51. package/web-app/dist/assets/{GitHubPRsPanel-BCzipPWD.js → GitHubPRsPanel-7g7ZTnkx.js} +1 -1
  52. package/web-app/dist/assets/{HomePage-CIdnkzaN.js → HomePage-CcNA24ny.js} +2 -2
  53. package/web-app/dist/assets/{LoginPage-D-178nTT.js → LoginPage-BYjlb-IK.js} +1 -1
  54. package/web-app/dist/assets/{MagicPage-BmHuzMT8.js → MagicPage-C0Dwt9Na.js} +1 -1
  55. package/web-app/dist/assets/{MetricsPage-CPCiyDFn.js → MetricsPage-jDGmdcgg.js} +1 -1
  56. package/web-app/dist/assets/{NotFoundPage-B2T6n1gx.js → NotFoundPage-C_ZO8tLE.js} +1 -1
  57. package/web-app/dist/assets/{ProjectPage-DQmpFhb0.js → ProjectPage-B5wnurfQ.js} +3 -3
  58. package/web-app/dist/assets/{ProjectsPage-BmugzFEd.js → ProjectsPage--EyKt5-D.js} +1 -1
  59. package/web-app/dist/assets/{SettingsPage-ZB-RrmgM.js → SettingsPage-cB0lnfti.js} +1 -1
  60. package/web-app/dist/assets/{ShowcasePage-BAhwHt4_.js → ShowcasePage-Dl16FDkK.js} +1 -1
  61. package/web-app/dist/assets/{SystemSettingsPage-Bt7sf2Vu.js → SystemSettingsPage-BmAXd4V2.js} +1 -1
  62. package/web-app/dist/assets/{TeamsPage-B-v5j_8q.js → TeamsPage-BFkWVOZe.js} +1 -1
  63. package/web-app/dist/assets/{TemplatesPage-bdqUL-LG.js → TemplatesPage-Bf2kmEUM.js} +1 -1
  64. package/web-app/dist/assets/{TerminalOutput-CRHEXBwS.js → TerminalOutput-BItv9Wa6.js} +1 -1
  65. package/web-app/dist/assets/{activity-C-ROkmXt.js → activity-Df2Awg6I.js} +1 -1
  66. package/web-app/dist/assets/{bell-DbiCBxQA.js → bell-D-1ztTsJ.js} +1 -1
  67. package/web-app/dist/assets/{bot-DsIgE_qr.js → bot-A4qgZsKi.js} +1 -1
  68. package/web-app/dist/assets/{check-CkAdmHWP.js → check-BPfFpF0c.js} +1 -1
  69. package/web-app/dist/assets/{chevron-left-CYTLivAo.js → chevron-left-CfSJvmDi.js} +1 -1
  70. package/web-app/dist/assets/{circle-alert-BVIcAsFJ.js → circle-alert-wx2d1dzb.js} +1 -1
  71. package/web-app/dist/assets/{clock-Dg3Rq0go.js → clock-pQMai6l-.js} +1 -1
  72. package/web-app/dist/assets/{cloud-CenrqHFl.js → cloud-DWL470r6.js} +1 -1
  73. package/web-app/dist/assets/{code-xml-C2mvfoug.js → code-xml-BaEQpQLZ.js} +1 -1
  74. package/web-app/dist/assets/{database-BKa0_eCb.js → database-h-N7WkQN.js} +1 -1
  75. package/web-app/dist/assets/{dollar-sign-DfelUK1S.js → dollar-sign-B5oYiD27.js} +1 -1
  76. package/web-app/dist/assets/{file-code-corner-CZKWoHVg.js → file-code-corner-fbTtTy0o.js} +1 -1
  77. package/web-app/dist/assets/{file-plus-CZ-m1zMX.js → file-plus-PaF6HMo8.js} +1 -1
  78. package/web-app/dist/assets/{globe-S0kv_GwC.js → globe-Ct-GlWID.js} +1 -1
  79. package/web-app/dist/assets/{hammer-BW1MDdu1.js → hammer-C1oZFX51.js} +1 -1
  80. package/web-app/dist/assets/{index-ACEYsTc2.js → index-C2egrRhz.js} +3 -3
  81. package/web-app/dist/assets/{layers-BrXHYZ-L.js → layers-Dt5ZK4v2.js} +1 -1
  82. package/web-app/dist/assets/{loader-circle-DOdhKFx0.js → loader-circle-DrpMziic.js} +1 -1
  83. package/web-app/dist/assets/{lock-BcFTyg8J.js → lock-DOez8MmK.js} +1 -1
  84. package/web-app/dist/assets/{package-D34cMVQG.js → package-ClGIkerr.js} +1 -1
  85. package/web-app/dist/assets/{plus-CEaMeBxL.js → plus-C004i9ua.js} +1 -1
  86. package/web-app/dist/assets/{refresh-cw-D2Rzxr5U.js → refresh-cw-Ddfk-7Sy.js} +1 -1
  87. package/web-app/dist/assets/{rotate-ccw-C_-EjRLh.js → rotate-ccw-Bu1KgDjS.js} +1 -1
  88. package/web-app/dist/assets/{scroll-text-BSSCMI_c.js → scroll-text-3q1lW5pm.js} +1 -1
  89. package/web-app/dist/assets/{server-BKvLonWt.js → server-pd6x4HW1.js} +1 -1
  90. package/web-app/dist/assets/{shield-alert-DbbQbPdW.js → shield-alert-B9jGJepT.js} +1 -1
  91. package/web-app/dist/assets/{thumbs-up-NoBD1fUR.js → thumbs-up-Cdcc9nQf.js} +1 -1
  92. package/web-app/dist/assets/{trash-2-BXz8rAyd.js → trash-2-foSojXHK.js} +1 -1
  93. package/web-app/dist/assets/{trending-down-D8u535Re.js → trending-down-CTTiqlJ1.js} +1 -1
  94. package/web-app/dist/assets/{trending-up-q8k3kgiy.js → trending-up-B_MgmRAx.js} +1 -1
  95. package/web-app/dist/assets/{upload-Cp7_zeBI.js → upload-g6BfALzG.js} +1 -1
  96. package/web-app/dist/assets/{usePolling-CTHVdcuX.js → usePolling-DrHwvzuh.js} +1 -1
  97. package/web-app/dist/assets/{user-DF7bfSf_.js → user-COp75m7l.js} +1 -1
  98. package/web-app/dist/index.html +1 -1
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.15.0
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.15.0 | [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.15.0
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
@@ -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
@@ -64,6 +71,98 @@ HLP
64
71
  }
65
72
 
66
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
+
67
166
  # Run a delegate without letting a non-zero exit end the session (autonomy/loki runs under set -e).
68
167
  _chat_do() { local _rc=0; "$@" || _rc=$?; [ "$_rc" -eq 0 ] || echo "(exit $_rc)"; return 0; }
69
168
 
@@ -318,7 +417,7 @@ Question: $line" || true
318
417
  /status) _chat_do "$loki" status; continue ;;
319
418
  /steer)
320
419
  [ -n "$rest" ] || { echo "usage: /steer <message>"; continue; }
321
- _chat_do "$loki" steer "$rest"; continue ;;
420
+ _chat_steer "$loki" "$rest"; continue ;;
322
421
  /stop) _chat_do "$loki" stop; continue ;;
323
422
  /detach) attached=0; echo "Detached."; continue ;;
324
423
  esac
@@ -328,10 +427,10 @@ Question: $line" || true
328
427
  /help) _chat_help ;;
329
428
  /plan) mode="plan"; echo "Plan mode: read-only." ;;
330
429
  /build) mode="build"; echo "Build mode: edits allowed." ;;
331
- /run)
430
+ /run|/start)
332
431
  [ -n "$rest" ] || { echo "usage: /run <prd|brief>"; continue; }
333
- _chat_run_start "$loki" "$rest"
334
- _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 ;;
335
434
  /workspace)
336
435
  local _ws_name="${rest%% *}" _ws_brief=""
337
436
  [ "$_ws_name" != "$rest" ] && _ws_brief="${rest#* }"
@@ -344,8 +443,13 @@ Question: $line" || true
344
443
  /status) _chat_do "$loki" status ;;
345
444
  /steer)
346
445
  [ -n "$rest" ] || { echo "usage: /steer <message>"; continue; }
347
- _chat_do "$loki" steer "$rest" ;;
446
+ _chat_steer "$loki" "$rest" ;;
348
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" ;;
349
453
  /detach) echo "Not attached." ;;
350
454
  /quick)
351
455
  [ -n "$rest" ] || { echo "usage: /quick <task>"; continue; }
@@ -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,7 +2,8 @@
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
8
  # engine10-push.sh issue-comment [--update] <issue-ref> <body-file>
8
9
  # engine10-push.sh status <sha> <pending|success|failure|error> <description>
@@ -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,10 +168,9 @@ 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"
171
+ url="$(_e10_find_pr "$branch")" || die "gh pr list failed"
162
172
  _e10_pr_state=reused
163
- if [ -z "$url" ] || [ "$url" = "null" ]; then
173
+ if [ -z "$url" ]; then
164
174
  _e10_pr_state=created
165
175
  url="$(_e10_gh pr create --repo "$_e10_repo" --head "$branch" --title "$title" --body-file "$body" "${draft[@]}" "${base[@]}")" \
166
176
  || die "gh pr create failed"
@@ -169,6 +179,21 @@ case "$mode" in
169
179
  printf '%s\n' "$_e10_pr_state" # marker line before the URL: reused (open PR found) or created
170
180
  printf '%s\n' "$url"
171
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"
195
+ printf '%s\n' "$url"
196
+ ;;
172
197
  comment)
173
198
  [ "$#" -eq 2 ] || die "usage: comment <pr-number> <body-file>"
174
199
  case "$1" in '' | *[!0-9]*) die "pr number must be digits" ;; esac
@@ -215,5 +240,5 @@ case "$mode" in
215
240
  _e10_gh api "repos/$_e10_repo/statuses/$1" -f "state=$2" -f context=loki/deep-verify \
216
241
  -f "description=$3" >/dev/null || die "gh api status failed"
217
242
  ;;
218
- *) 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 ..." ;;
219
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
@@ -1455,6 +1455,11 @@ def _collect_journey(loki_dir):
1455
1455
  },
1456
1456
  "waited_s": ws,
1457
1457
  }
1458
+ # active_s (measured active human seconds) rides along only when
1459
+ # recorded; absent stays absent, never a fabricated 0.
1460
+ act = iv.get("active_s")
1461
+ if isinstance(act, int) and not isinstance(act, bool) and act >= 0:
1462
+ out["interventions_detail"]["active_s"] = act
1458
1463
 
1459
1464
  # PR state/URL, written by the consent-gated PR step. "prepared" means a PR
1460
1465
  # body exists locally and GitHub was NOT mutated.
@@ -1173,10 +1173,34 @@ def git_head(repo):
1173
1173
  return out.strip() if out and out.strip() else ""
1174
1174
 
1175
1175
 
1176
+ def _sparse_included(repo, paths):
1177
+ """Set of `paths` git's own sparse matcher includes; None when git cannot answer.
1178
+
1179
+ One `git sparse-checkout check-rules` call for the whole batch: it echoes
1180
+ the paths the patterns include, for cone, non-cone and negation alike.
1181
+ Known limit: a .git directory writable by the subject can drop paths from
1182
+ info/sparse-checkout. That hole predates this check (core.sparseCheckout=true
1183
+ alone was enough).
1184
+ """
1185
+ enc = [p.encode("utf-8", "surrogateescape") for p in paths]
1186
+ try:
1187
+ r = subprocess.run(
1188
+ ["git", "-C", repo or ".", "sparse-checkout", "check-rules", "-z"],
1189
+ input=b"".join(e + b"\0" for e in enc),
1190
+ capture_output=True, timeout=60, env=_scoped_env())
1191
+ except (OSError, subprocess.SubprocessError, ValueError):
1192
+ return None
1193
+ if r.returncode != 0:
1194
+ return None
1195
+ out = set(r.stdout.split(b"\0"))
1196
+ return set(p for p, e in zip(paths, enc) if e in out)
1197
+
1198
+
1176
1199
  def git_dirty(repo):
1177
1200
  """True/False, or None when git cannot answer. .loki/ is excluded: the
1178
1201
  verifier writes its own artifacts there."""
1179
- out = _vr_git_args(repo, ["status", "--porcelain"])
1202
+ out = _vr_git_args(repo, ["status", "--porcelain", "--untracked-files=all",
1203
+ "--no-renames", "--ignore-submodules=none"])
1180
1204
  if out is None:
1181
1205
  return None
1182
1206
  for line in out.splitlines():
@@ -1185,6 +1209,90 @@ def git_dirty(repo):
1185
1209
  path = path.strip('"')
1186
1210
  if not (path.startswith(".loki/") or path == ".loki"):
1187
1211
  return True
1212
+ # A skip-worktree (S/s) or assume-unchanged (lowercase tag) flag hides edits
1213
+ # from status. A flagged path is dirty only when its content differs from
1214
+ # the index; a skip-worktree path absent on disk is sparse-excluded (clean).
1215
+ flagged = _vr_git_args(repo, ["ls-files", "-v", "-z"])
1216
+ if flagged is None:
1217
+ return None
1218
+ cand = []
1219
+ for ent in flagged.split("\0"):
1220
+ tag, path = ent[:1], ent[2:]
1221
+ if not path or path.startswith(".loki/"):
1222
+ continue
1223
+ if tag in ("S", "s") or tag.islower():
1224
+ cand.append((tag in ("S", "s"), path))
1225
+ if not cand:
1226
+ return False
1227
+ staged = _vr_git_args(repo, ["ls-files", "-s", "-z"])
1228
+ if staged is None:
1229
+ return None
1230
+ blobs = {}
1231
+ for ent in staged.split("\0"):
1232
+ meta, _, p = ent.partition("\t")
1233
+ f = meta.split()
1234
+ if len(f) == 3 and f[2] == "0":
1235
+ blobs[p] = (f[0], f[1])
1236
+ sparse = (_vr_git_args(repo, ["config", "--bool", "core.sparseCheckout"]) or "").strip() == "true"
1237
+ root = repo or "."
1238
+ bad = None
1239
+ todo = []
1240
+ sparse_missing = []
1241
+ for skip, path in cand:
1242
+ full = os.path.join(root, path)
1243
+ mode, want = blobs.get(path, ("", ""))
1244
+ if not os.path.lexists(full):
1245
+ if skip and sparse:
1246
+ # Decided after the loop in one batched git call: clean only when
1247
+ # git's sparse patterns exclude the path. An in-cone path flagged
1248
+ # by hand and deleted is a real deletion; fail closed otherwise.
1249
+ sparse_missing.append(path)
1250
+ continue
1251
+ bad = (skip, path)
1252
+ break
1253
+ if mode == "160000":
1254
+ continue
1255
+ if os.path.islink(full):
1256
+ data = os.readlink(full).encode("utf-8", "surrogateescape")
1257
+ if hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() != want:
1258
+ bad = (skip, path)
1259
+ break
1260
+ continue
1261
+ todo.append((skip, path))
1262
+ if sparse_missing and not bad:
1263
+ included = _sparse_included(repo, sparse_missing)
1264
+ for path in sparse_missing:
1265
+ if included is None or path in included:
1266
+ bad = (True, path)
1267
+ break
1268
+ for i in range(0, len(todo), 200):
1269
+ if bad:
1270
+ break
1271
+ chunk = todo[i:i + 200]
1272
+ out = _vr_git_args(repo, ["hash-object", "--"] + [p for _, p in chunk])
1273
+ if out is None:
1274
+ return None
1275
+ miss = [(s, p) for (s, p), h in zip(chunk, out.split())
1276
+ if h != blobs.get(p, ("", ""))[1]]
1277
+ if miss:
1278
+ # Identical raw bytes mean clean (CRLF in the index plus autocrlf).
1279
+ # The clean filter is not idempotent: the index may hold unfiltered
1280
+ # bytes that a filtered hash of the worktree file never reproduces,
1281
+ # so the filtered hash alone would call such a file dirty.
1282
+ raw = _vr_git_args(repo, ["hash-object", "--no-filters", "--"] + [p for _, p in miss])
1283
+ if raw is None:
1284
+ return None
1285
+ for (skip, path), h in zip(miss, raw.split()):
1286
+ if h != blobs.get(path, ("", ""))[1]:
1287
+ bad = (skip, path)
1288
+ break
1289
+ if bad:
1290
+ skip, path = bad
1291
+ kind, fix = (("skip-worktree", "--no-skip-worktree") if skip
1292
+ else ("assume-unchanged", "--no-assume-unchanged"))
1293
+ sys.stderr.write("dirty: %s is flagged %s and differs from the index, which hides edits from the proof. "
1294
+ "Run: git update-index %s -- %s\n" % (path, kind, fix, path))
1295
+ return True
1188
1296
  return False
1189
1297
 
1190
1298