@meetless/mla 0.1.4

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 (202) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +81 -0
  3. package/dist/build-info.json +9 -0
  4. package/dist/bundles/ask-core.js +396 -0
  5. package/dist/bundles/mcp.js +16592 -0
  6. package/dist/bundles/trace-core.js +263 -0
  7. package/dist/cli.js +828 -0
  8. package/dist/commands/activate.js +781 -0
  9. package/dist/commands/adoption.js +130 -0
  10. package/dist/commands/ask.js +290 -0
  11. package/dist/commands/context.js +114 -0
  12. package/dist/commands/debug.js +313 -0
  13. package/dist/commands/doctor.js +1021 -0
  14. package/dist/commands/enrich.js +427 -0
  15. package/dist/commands/evidence.js +229 -0
  16. package/dist/commands/flush.js +184 -0
  17. package/dist/commands/graph.js +104 -0
  18. package/dist/commands/init.js +272 -0
  19. package/dist/commands/internal-active-review.js +322 -0
  20. package/dist/commands/internal-auto-index.js +188 -0
  21. package/dist/commands/internal-capture-decisions.js +320 -0
  22. package/dist/commands/internal-evidence-correlate.js +239 -0
  23. package/dist/commands/internal-evidence-hooks.js +240 -0
  24. package/dist/commands/internal-evidence-inject.js +231 -0
  25. package/dist/commands/internal-finalize.js +221 -0
  26. package/dist/commands/internal-pretool-observe.js +225 -0
  27. package/dist/commands/internal-refresh.js +136 -0
  28. package/dist/commands/internal-session-nudge.js +120 -0
  29. package/dist/commands/internal-steer-sync.js +117 -0
  30. package/dist/commands/internal-turn-recap.js +140 -0
  31. package/dist/commands/kb.js +375 -0
  32. package/dist/commands/kb_add.js +681 -0
  33. package/dist/commands/kb_forget.js +283 -0
  34. package/dist/commands/kb_move.js +45 -0
  35. package/dist/commands/kb_pending.js +410 -0
  36. package/dist/commands/kb_personal.js +149 -0
  37. package/dist/commands/kb_promote.js +188 -0
  38. package/dist/commands/kb_purge.js +168 -0
  39. package/dist/commands/kb_reingest.js +335 -0
  40. package/dist/commands/kb_retime.js +170 -0
  41. package/dist/commands/kb_review.js +391 -0
  42. package/dist/commands/kb_revision.js +179 -0
  43. package/dist/commands/kb_show.js +385 -0
  44. package/dist/commands/label.js +226 -0
  45. package/dist/commands/login.js +295 -0
  46. package/dist/commands/logout.js +108 -0
  47. package/dist/commands/mcp-supervisor.js +93 -0
  48. package/dist/commands/mcp.js +227 -0
  49. package/dist/commands/queue-prune.js +98 -0
  50. package/dist/commands/review.js +358 -0
  51. package/dist/commands/rewire.js +124 -0
  52. package/dist/commands/rules.js +728 -0
  53. package/dist/commands/scan-context.js +67 -0
  54. package/dist/commands/session.js +347 -0
  55. package/dist/commands/stats.js +479 -0
  56. package/dist/commands/status.js +61 -0
  57. package/dist/commands/summary.js +250 -0
  58. package/dist/commands/turn.js +114 -0
  59. package/dist/commands/uninstall.js +222 -0
  60. package/dist/commands/whoami.js +102 -0
  61. package/dist/commands/workspace.js +130 -0
  62. package/dist/hooks-template/ce0-post-tool-use.sh +34 -0
  63. package/dist/hooks-template/ce0-session-start.sh +49 -0
  64. package/dist/hooks-template/ce0-stop.sh +29 -0
  65. package/dist/hooks-template/ce0-user-prompt-submit.sh +38 -0
  66. package/dist/hooks-template/common.sh +934 -0
  67. package/dist/hooks-template/event-batch-filter.jq +67 -0
  68. package/dist/hooks-template/flush.sh +503 -0
  69. package/dist/hooks-template/post-tool-use.sh +423 -0
  70. package/dist/hooks-template/pre-tool-use.sh +69 -0
  71. package/dist/hooks-template/session-start.sh +140 -0
  72. package/dist/hooks-template/stop.sh +308 -0
  73. package/dist/hooks-template/user-prompt-submit.sh +1162 -0
  74. package/dist/lib/activation.js +79 -0
  75. package/dist/lib/active-conflict-cache.js +141 -0
  76. package/dist/lib/active-memory.js +59 -0
  77. package/dist/lib/active-review-runner.js +26 -0
  78. package/dist/lib/agent-decision/index.js +25 -0
  79. package/dist/lib/agent-decision/keys.js +49 -0
  80. package/dist/lib/agent-decision/normalize-claude.js +183 -0
  81. package/dist/lib/agent-decision/types.js +21 -0
  82. package/dist/lib/agent-decision/validate.js +216 -0
  83. package/dist/lib/analytics/capture.js +96 -0
  84. package/dist/lib/analytics/command-event.js +267 -0
  85. package/dist/lib/analytics/consent.js +58 -0
  86. package/dist/lib/analytics/coverage-gap.js +96 -0
  87. package/dist/lib/analytics/envelope.js +236 -0
  88. package/dist/lib/analytics/event-id.js +86 -0
  89. package/dist/lib/analytics/evidence.js +150 -0
  90. package/dist/lib/analytics/followthrough.js +194 -0
  91. package/dist/lib/analytics/forwarder.js +109 -0
  92. package/dist/lib/analytics/logs.js +78 -0
  93. package/dist/lib/analytics/metrics.js +78 -0
  94. package/dist/lib/analytics/recorder.js +92 -0
  95. package/dist/lib/analytics/review-analytics.js +75 -0
  96. package/dist/lib/analytics/sequence.js +77 -0
  97. package/dist/lib/analytics/store.js +131 -0
  98. package/dist/lib/analytics/turn-recap.js +279 -0
  99. package/dist/lib/artifact_id.js +108 -0
  100. package/dist/lib/auth-breaker.js +161 -0
  101. package/dist/lib/auto-index.js +112 -0
  102. package/dist/lib/classifier.js +88 -0
  103. package/dist/lib/config.js +298 -0
  104. package/dist/lib/conflict-advisory.js +64 -0
  105. package/dist/lib/debug-bundle.js +520 -0
  106. package/dist/lib/enrichment/ingest.js +301 -0
  107. package/dist/lib/enrichment/plan.js +253 -0
  108. package/dist/lib/enrichment/protocol.js +359 -0
  109. package/dist/lib/enrichment/scout-brief.js +176 -0
  110. package/dist/lib/failure-telemetry.js +444 -0
  111. package/dist/lib/git.js +200 -0
  112. package/dist/lib/governance-cache.js +77 -0
  113. package/dist/lib/governed-path-cache.js +76 -0
  114. package/dist/lib/http.js +677 -0
  115. package/dist/lib/identity-envelope.js +23 -0
  116. package/dist/lib/kb-candidate.js +65 -0
  117. package/dist/lib/kb_acl.js +98 -0
  118. package/dist/lib/login.js +353 -0
  119. package/dist/lib/mcp-fetchers.js +130 -0
  120. package/dist/lib/mcp-restart.js +47 -0
  121. package/dist/lib/observability.js +805 -0
  122. package/dist/lib/open-url.js +33 -0
  123. package/dist/lib/orphan-guard.js +70 -0
  124. package/dist/lib/packaged.js +21 -0
  125. package/dist/lib/reconcile-sessions.js +171 -0
  126. package/dist/lib/redactor.js +89 -0
  127. package/dist/lib/relationship-candidate-query.js +27 -0
  128. package/dist/lib/render.js +611 -0
  129. package/dist/lib/rules/applicability.js +64 -0
  130. package/dist/lib/rules/attest-code-rule-version.js +47 -0
  131. package/dist/lib/rules/attest-notes-location.js +217 -0
  132. package/dist/lib/rules/attest-rule-version.js +69 -0
  133. package/dist/lib/rules/canonical-json.js +97 -0
  134. package/dist/lib/rules/ce0-emit.js +64 -0
  135. package/dist/lib/rules/ce0-evidence.js +281 -0
  136. package/dist/lib/rules/ce0-recall-sample.js +82 -0
  137. package/dist/lib/rules/ce0-rule.js +55 -0
  138. package/dist/lib/rules/ce0-sampling-bucket.js +15 -0
  139. package/dist/lib/rules/ce0-store.js +683 -0
  140. package/dist/lib/rules/ce0-telemetry-project.js +93 -0
  141. package/dist/lib/rules/ce0-telemetry.js +158 -0
  142. package/dist/lib/rules/code-rule-registry.js +17 -0
  143. package/dist/lib/rules/command-match.js +185 -0
  144. package/dist/lib/rules/consult-evidence-binding.js +27 -0
  145. package/dist/lib/rules/consultation-capture-adapter.js +193 -0
  146. package/dist/lib/rules/content-match.js +56 -0
  147. package/dist/lib/rules/deny-admission.js +99 -0
  148. package/dist/lib/rules/durable-observation.js +190 -0
  149. package/dist/lib/rules/enforce-notes-version.js +421 -0
  150. package/dist/lib/rules/evaluation-input-hash.js +126 -0
  151. package/dist/lib/rules/evaluator.js +108 -0
  152. package/dist/lib/rules/inert-rule-families.js +51 -0
  153. package/dist/lib/rules/input-authority-resolver.js +241 -0
  154. package/dist/lib/rules/interception-schema.js +170 -0
  155. package/dist/lib/rules/interception-store.js +267 -0
  156. package/dist/lib/rules/live-input-authority.js +66 -0
  157. package/dist/lib/rules/local-matcher.js +108 -0
  158. package/dist/lib/rules/local-observe.js +79 -0
  159. package/dist/lib/rules/local-rule-version-repo.js +214 -0
  160. package/dist/lib/rules/memory-requirement.js +109 -0
  161. package/dist/lib/rules/notes-observe.js +39 -0
  162. package/dist/lib/rules/notes-path.js +261 -0
  163. package/dist/lib/rules/notes-rule.js +75 -0
  164. package/dist/lib/rules/observe-adapter.js +114 -0
  165. package/dist/lib/rules/observed-rule-hash.js +119 -0
  166. package/dist/lib/rules/prompt-submit-adapter.js +132 -0
  167. package/dist/lib/rules/requirement-subject.js +240 -0
  168. package/dist/lib/rules/rule-activity.js +67 -0
  169. package/dist/lib/rules/rule-version-hash.js +151 -0
  170. package/dist/lib/rules/runtime-scope.js +55 -0
  171. package/dist/lib/rules/stop-adapter.js +116 -0
  172. package/dist/lib/rules/stop-response-snapshot.js +174 -0
  173. package/dist/lib/rules/types.js +10 -0
  174. package/dist/lib/rules/ulid.js +46 -0
  175. package/dist/lib/rules/version-evaluation.js +156 -0
  176. package/dist/lib/scanner/agent-memory.js +99 -0
  177. package/dist/lib/scanner/bootstrap-summary.js +87 -0
  178. package/dist/lib/scanner/cache.js +59 -0
  179. package/dist/lib/scanner/frontmatter.js +42 -0
  180. package/dist/lib/scanner/parse-directives.js +69 -0
  181. package/dist/lib/scanner/parse-structured.js +72 -0
  182. package/dist/lib/scanner/render.js +73 -0
  183. package/dist/lib/scanner/scan.js +132 -0
  184. package/dist/lib/scanner/score.js +38 -0
  185. package/dist/lib/scanner/scout-mission.js +126 -0
  186. package/dist/lib/scanner/types.js +7 -0
  187. package/dist/lib/session-scope.js +195 -0
  188. package/dist/lib/spool.js +355 -0
  189. package/dist/lib/staleness.js +100 -0
  190. package/dist/lib/steer-cache.js +87 -0
  191. package/dist/lib/tagged-reference.js +20 -0
  192. package/dist/lib/temporal.js +109 -0
  193. package/dist/lib/turn-recap-emit.js +67 -0
  194. package/dist/lib/unwire.js +253 -0
  195. package/dist/lib/update-check.js +469 -0
  196. package/dist/lib/update-notifier.js +217 -0
  197. package/dist/lib/upgrade-apply.js +643 -0
  198. package/dist/lib/wire.js +1087 -0
  199. package/dist/lib/workspace.js +96 -0
  200. package/dist/lib/zip.js +154 -0
  201. package/dist/pretool-entry.js +37 -0
  202. package/package.json +75 -0
@@ -0,0 +1,67 @@
1
+ # event-batch-filter.jq: Pass 2 batch transform for flush.sh.
2
+ #
3
+ # Reads RAW newline-separated JSONL ($SESSION_ID.jsonl.draining.$$) and emits a
4
+ # JSON array of event records shaped for control's IngestAgentRunEventsDto.
5
+ #
6
+ # Tolerance contract (Wedge v6 Epoch 25):
7
+ # - ANY malformed line is silently skipped via `fromjson?`.
8
+ # - Empty lines are skipped via `select(length > 0)`.
9
+ # - One corrupt line CANNOT poison the whole batch.
10
+ #
11
+ # Pre-fix the filter used `jq -s` (slurp) which parses the entire file as a
12
+ # JSON-value stream. A single bad line (writer killed mid-printf, disk
13
+ # pressure, etc.) caused jq to exit non-zero; flush.sh's `|| echo "[]"`
14
+ # fallback then dropped EVERY valid event in the batch. The spool kept the
15
+ # raw lines only via Pass 2's re-spool on PATCH failure, but a successful
16
+ # PATCH-of-empty would silently advance.
17
+ #
18
+ # Invoked from flush.sh as:
19
+ # jq -c -R -s -f <abs-path>/event-batch-filter.jq < "$TMP"
20
+ #
21
+ # `-R` reads each line as a string; `-s` slurps the whole file into one
22
+ # string; `split("\n")` re-splits to lines. `fromjson?` per-line tolerates
23
+ # garbage.
24
+ #
25
+ # Whitelist gate (notes/20260608-agent-decision-capture-design.md section 5):
26
+ # an event type NOT named here is silently dropped with NO error. So
27
+ # `agent_decision_captured` MUST appear in the select() below or every captured
28
+ # agent-human decision vanishes between the spool and control, looking healthy.
29
+ #
30
+ # Transport source model (spec section 6): a captured decision carries the
31
+ # stronger envelope `{ source: "agent_adapter", provider, adapter }` instead of
32
+ # the generic `source: "claude_hook"`, so future providers do not overload
33
+ # `source`. `provider`/`adapter` are TRANSPORT metadata lifted to top level from
34
+ # the payload (`provider` / `providerSource`); control validates them for
35
+ # AGREEMENT with the canonical payload before writing a row
36
+ # (INV-ENVELOPE-PAYLOAD-CONSISTENCY), so they must mirror the payload exactly.
37
+ [
38
+ split("\n")[]
39
+ | select(length > 0)
40
+ | fromjson?
41
+ | select(.event == "prompt_submitted"
42
+ or .event == "tool_used_bash"
43
+ or .event == "tool_used_file"
44
+ or .event == "session_stopped"
45
+ or .event == "agent_decision_captured"
46
+ or .event == "injection_trace"
47
+ or .event == "assistant_message")
48
+ | if .event == "agent_decision_captured" then
49
+ {
50
+ eventKey: .eventKey,
51
+ eventType: .event,
52
+ occurredAt: .ts,
53
+ source: "agent_adapter",
54
+ provider: (.payload.provider),
55
+ adapter: (.payload.providerSource),
56
+ payload: (.payload // {})
57
+ }
58
+ else
59
+ {
60
+ eventKey: .eventKey,
61
+ eventType: .event,
62
+ occurredAt: .ts,
63
+ source: "claude_hook",
64
+ payload: (.payload // {})
65
+ }
66
+ end
67
+ ]
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env bash
2
+ # flush.sh: background flusher. Called detached by hooks; also invokable by
3
+ # `mla flush`.
4
+ #
5
+ # Invariants:
6
+ # - Idempotent (eventKey is required + unique per runId)
7
+ # - Safe under concurrent hook writers via shared $SESSION_ID.lock
8
+ # - Orphan *.draining.* snapshots from prior interrupted flushes are
9
+ # recovered on every run (Correction 11) under the same lock, before
10
+ # detaching the active queue file
11
+ # - Server dedupes on (runId, eventKey); re-POSTing a recovered line is safe
12
+ # - finalize-session takes ONLY sessionId (Correction 6); finalMessage is
13
+ # persisted on the session_stopped event
14
+ #
15
+ # Source: notes/20260527-bare-bones-mvp-codebase-evaluation-and-plan.md §5.2.
16
+ source "$(dirname "$0")/common.sh"
17
+ shopt -s nullglob 2>/dev/null || true
18
+
19
+ SESSION_ID="${1:?session id required}"
20
+ QUEUE_FILE="$QUEUE_DIR/$SESSION_ID.jsonl"
21
+ LOCK="$QUEUE_DIR/$SESSION_ID.lock"
22
+
23
+ CONTROL_URL="$(jq -r '.controlUrl' "$CFG")"
24
+ # Bearer for control. cli-config is now nested-auth-only on disk (the top-level
25
+ # controlToken is a read-time projection the TS layer adds, never persisted), so
26
+ # read auth.accessToken first and fall back to a legacy top-level controlToken for
27
+ # a pre-cutover config. A logged-out config (auth.mode 'none') yields empty here;
28
+ # the POSTs below then 401 and the fail-soft path re-spools, as designed.
29
+ TOKEN="$(jq -r '.auth.accessToken // .controlToken // empty' "$CFG")"
30
+ # Part 3 (reactive refresh-on-401): the auth mode gates whether a 401 from a
31
+ # capture POST may trigger a token refresh. Only `user-token` sessions can
32
+ # refresh (shared-key/none have no refresh token); read it once here. Empty for a
33
+ # legacy config => the retry path never fires and behaviour is exactly as before.
34
+ AUTH_MODE="$(jq -r '.auth.mode // empty' "$CFG" 2>/dev/null || true)"
35
+
36
+ # T1.4 transport (folder = workspace): the T0.2 AgentReviewWorkspaceGuard rejects
37
+ # any capture write whose actor cannot be resolved, and resolveActorIdentity reads
38
+ # the actor ONLY from the X-Meetless-Actor header (or a body actorUserId). The TS
39
+ # http client (src/lib/http.ts) stamps this header on every control request, but
40
+ # flush.sh is the capture transport and does NOT go through that client, so it must
41
+ # stamp the header itself. Without it EVERY POST/PATCH below 403s ("Actor identity
42
+ # required") and the fail-soft path re-spools forever (capture 100% down while
43
+ # looking healthy). Read the actor from cli-config the same way user-prompt-submit.sh
44
+ # does (jq -r '.actorUserId // empty'); only stamp the header when present (an absent
45
+ # actor 403s exactly as before and the fail-soft path handles it).
46
+ ACTOR_USER_ID="$(jq -r '.actorUserId // empty' "$CFG" 2>/dev/null || true)"
47
+ ACTOR_HEADER=()
48
+ if [[ -n "${ACTOR_USER_ID:-}" ]]; then
49
+ ACTOR_HEADER=(-H "X-Meetless-Actor: $ACTOR_USER_ID")
50
+ fi
51
+
52
+ # T1.2 hard cutover (folder = workspace): the marker is the ONLY source of the
53
+ # workspaceId. common.sh now leaves WORKSPACE_ID empty here (flush.sh is
54
+ # nohup-detached with cwd=$HOME, so it MUST NOT call meetless_activated -- the
55
+ # walk-up would miss the repo marker). session-start.sh snapshotted the resolved
56
+ # marker id into this sidecar; source it so every POST below carries the marker
57
+ # id. Missing/empty sidecar => empty WORKSPACE_ID => the guard skips the POST.
58
+ WS_SIDECAR="$QUEUE_DIR/$SESSION_ID.workspaceId"
59
+ if [[ -s "$WS_SIDECAR" ]]; then
60
+ WORKSPACE_ID="$(cat "$WS_SIDECAR")"
61
+ fi
62
+
63
+ if [[ -z "${WORKSPACE_ID:-}" ]]; then
64
+ # No workspace resolved (no marker sidecar): nothing safe to POST. Leave queue
65
+ # intact for a future flush (mla doctor will warn the user).
66
+ exit 0
67
+ fi
68
+
69
+ # Correction 5 + 11: writers AND drainer use the SAME lock file. Non-blocking
70
+ # acquire (already-running flush exits cleanly; next hook write wakes the next
71
+ # flush). Orphan recovery happens INSIDE the lock, BEFORE detach, so a crash
72
+ # mid-POST in a previous flush cannot strand events forever.
73
+ exec 9>"$LOCK"
74
+ flock -n 9 || { log "skip: another flush already holds the session lock"; exit 0; }
75
+
76
+ # Correction 11: recover orphaned *.draining.* snapshots from prior interrupted
77
+ # flushes (laptop sleep, terminal close, SIGKILL, mid-POST crash). Concat back
78
+ # into the active queue file; the next loop below drains them. Server dedupes
79
+ # on (runId, eventKey), so re-POSTing already-delivered lines is safe.
80
+ ORPHAN_COUNT=0
81
+ for ORPHAN in "$QUEUE_DIR/$SESSION_ID.jsonl.draining."*; do
82
+ [[ -f "$ORPHAN" ]] || continue
83
+ cat "$ORPHAN" >> "$QUEUE_FILE"
84
+ rm -f "$ORPHAN"
85
+ ORPHAN_COUNT=$((ORPHAN_COUNT + 1))
86
+ done
87
+ if [[ "$ORPHAN_COUNT" -gt 0 ]]; then
88
+ log "recovered $ORPHAN_COUNT orphaned snapshot(s) from a prior interrupted flush"
89
+ fi
90
+
91
+ # Cross-session steer pull (Plan 1, conflict-resolution loop). Refresh this
92
+ # session's pending human steers into the local cache the UserPromptSubmit hook
93
+ # reads with zero network, and mark-injected any the hook already surfaced (PULLED
94
+ # -> INJECTED). Runs under the lock once per flush, even on an orphan-only/empty
95
+ # drain so an idle-but-flushing session still refreshes steers. Best-effort and
96
+ # time-bounded: a failure here NEVER affects the capture drain below. mla resolves
97
+ # its own config + auth; we only pass the session id. Gated on an executable mla
98
+ # (same as Pass 3).
99
+ if [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]]; then
100
+ # Patch 2: explicit branch over a clever parameter expansion. Hook code should be
101
+ # boring: wrap in `timeout`/`gtimeout` when one is on PATH, otherwise call mla
102
+ # directly. Either way the call is best-effort (`|| true`) so it never breaks the
103
+ # drain below.
104
+ STEER_TIMEOUT="$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null || true)"
105
+ if [[ -n "$STEER_TIMEOUT" ]]; then
106
+ "$STEER_TIMEOUT" 6 "$MLA_PATH" _internal steer-sync --session "$SESSION_ID" >/dev/null 2>&1 || true
107
+ else
108
+ "$MLA_PATH" _internal steer-sync --session "$SESSION_ID" >/dev/null 2>&1 || true
109
+ fi
110
+ fi
111
+
112
+ # After recovery: if nothing to drain, exit cleanly.
113
+ if [[ ! -s "$QUEUE_FILE" ]]; then
114
+ log "nothing to flush (queue empty after orphan recovery)"
115
+ rm -f "$QUEUE_FILE"
116
+ exec 9>&-
117
+ exit 0
118
+ fi
119
+
120
+ # Atomic detach + truncate under lock so hook writers landing during drain
121
+ # go to the NEW empty file, not the snapshot being processed.
122
+ TMP="$QUEUE_FILE.draining.$$"
123
+ mv "$QUEUE_FILE" "$TMP"
124
+ : > "$QUEUE_FILE"
125
+
126
+ # Wedge v6 (dogfood incident 2026-06-22): collapse duplicate lines in the
127
+ # detached snapshot before draining, keyed by the per-event eventKey. The spool
128
+ # can accumulate the SAME line hundreds of thousands of times during a control
129
+ # outage: Pass 1/2 re-spool the failed line (spool_append), and a flush
130
+ # interrupted before the end-of-flush `rm -f "$TMP"` leaves a *.draining.$$
131
+ # orphan that the NEXT flush's orphan recovery cats straight back into the queue.
132
+ # Those two paths COMPOUND geometrically (~2x per interrupted cycle), so a single
133
+ # session_started can reach hundreds of thousands of copies (observed: a 367MB
134
+ # spool with 859,723 identical session_started lines). Pass 1 then fires one
135
+ # POST /internal/v1/agent-runs per copy -- a self-inflicted DDoS on control,
136
+ # every hit an idempotent no-op.
137
+ #
138
+ # The server already dedupes on (runId, eventKey), so collapsing to one line per
139
+ # eventKey here is loss-free and bounds EVERY pass to the count of DISTINCT
140
+ # events. It also caps the queue's worst case at ~2x distinct (one re-spool + one
141
+ # orphan re-cat) before the next detach collapses it again, so the geometric
142
+ # growth can never restart. Lines with no parseable eventKey are keyed by line
143
+ # number so malformed/unkeyed lines are NEVER collapsed away and still drain.
144
+ # DEDUP_TMP sits in $QUEUE_DIR for an atomic same-filesystem rename, but is named
145
+ # OUTSIDE the *.jsonl.draining.* orphan-recovery glob (see the Pass 1 mktemp
146
+ # note) so a crash mid-dedup can never re-inject it as a bogus queue line. If awk
147
+ # is missing or errors, we drain the snapshot unchanged -- server-side
148
+ # (runId, eventKey) dedup still guarantees correctness, just without the cap.
149
+ DEDUP_TMP="$QUEUE_DIR/$SESSION_ID.dedup.$$"
150
+ if awk '{
151
+ if (match($0, /"eventKey":"[^"]*"/)) key = substr($0, RSTART, RLENGTH);
152
+ else key = "__nokey__" NR;
153
+ if (!seen[key]++) print
154
+ }' "$TMP" > "$DEDUP_TMP" 2>/dev/null; then
155
+ mv -f "$DEDUP_TMP" "$TMP"
156
+ else
157
+ rm -f "$DEDUP_TMP"
158
+ fi
159
+
160
+ log "draining $(wc -l < "$TMP" 2>/dev/null | tr -d ' ' || echo '?') queued line(s) -> $CONTROL_URL"
161
+
162
+ # Release the lock once the snapshot is detached. Hook writers can append
163
+ # concurrently while we POST.
164
+ exec 9>&-
165
+
166
+ HAS_FINALIZE=0
167
+ EVENTS_OK=1
168
+ # Set to 1 only after a successful finalize (Pass 3 OK branch). Gates the
169
+ # end-of-flush full-sidecar reap below: an ended, cleanly-finalized session has
170
+ # no future turn, so its .turn/.lock/.hb*/.narration-cursor* sidecars are litter
171
+ # we reclaim now instead of leaving 6 files for the 24h age-gated reaper.
172
+ FINALIZE_OK=0
173
+
174
+ # Process the snapshot in two passes:
175
+ # Pass 1: session_started lines -> POST /internal/v1/agent-runs (one per line).
176
+ # Pass 2: prompt_submitted | tool_used_bash | tool_used_file | session_stopped
177
+ # | agent_decision_captured | injection_trace | assistant_message lines
178
+ # -> batched PATCH
179
+ # /internal/v1/agent-runs/by-session/:sid/events. The forward whitelist
180
+ # (event-batch-filter.jq) and the re-spool whitelist below MUST list
181
+ # the same types or a type forwards on success but vanishes on retry.
182
+ # finalize_requested is a control signal only; it never POSTs by itself, it
183
+ # triggers the `mla _internal finalize-session` hop at the end. The server
184
+ # dedupes on (runId, eventKey), so re-POSTing a batch on retry is safe.
185
+ #
186
+ # Each raw JSONL line is shaped `{ts, event, eventKey, sessionId, payload}`.
187
+ # Control's DTOs require Nest-flavored shapes (CreateAgentRunDto +
188
+ # IngestAgentRunEventsDto). The jq transforms below map fields:
189
+ # ts -> startedAt | occurredAt
190
+ # event -> eventType
191
+ # sessionId -> externalSessionId
192
+ # payload -> kept as-is + selected fields lifted to top level
193
+ # All POSTs include workspaceId, sourced from the per-session .workspaceId
194
+ # sidecar (the marker id snapshotted at session start). See T1.2 cutover above.
195
+ #
196
+ # curl keeps `-f` so HTTP 4xx/5xx are still failures (exit 22) that re-spool;
197
+ # a control-side validation error or 5xx is never silently swallowed. We ALSO add
198
+ # `-w '%{http_code}'` (which curl prints even when `-f` fails) so the specific
199
+ # auth/visibility codes 401 / 403 / 404 can fail SOFT: on failure the flusher
200
+ # fires a throttled local warning via warn_capture_auth (common.sh) instead of
201
+ # going silent, because "committed marker, token not yet a workspace member"
202
+ # (403) is a common transient onboarding state. Success/failure still branches on
203
+ # curl's exit code (CURL_RC), so the contract is unchanged for every other code;
204
+ # the status code only selects whether to warn. A transport error yields code 000
205
+ # (no warn) and re-spools. The flusher never blocks the session (always exits 0).
206
+
207
+ # control_capture_curl METHOD URL BODY: perform a capture write with the current
208
+ # $TOKEN, then (Part 3 §B reactive refresh-on-401) recover a single expired-access
209
+ # token. Sets globals HTTP_CODE (the %{http_code}) and CURL_RC (curl's exit code)
210
+ # for the caller to branch on, EXACTLY as the inline curl did before, so the
211
+ # success/fail/warn/re-spool contract downstream is unchanged. The only added
212
+ # behaviour: when the first attempt fails with HTTP 401 AND the session is
213
+ # `auth.mode == user-token`, fire one synchronous `mla _internal refresh`
214
+ # (refresh_user_token, common.sh). On rc 0 (token rotated) re-read the rotated
215
+ # access token and retry the SAME request EXACTLY ONCE. Any other refresh outcome
216
+ # (busy 75 / expired 77 / not-attempted 70 / wrong-mode 64) leaves the original
217
+ # failure in place for the caller's existing fail-soft path. One-shot, never a
218
+ # loop: a still-401 retry is returned as-is. set -e-safe via `|| CURL_RC=$?` and
219
+ # `|| refresh_rc=$?` (this whole script runs under common.sh's set -euo pipefail).
220
+ control_capture_curl() {
221
+ # body_file, not an inline body string. A busy session's Pass 2 events[]
222
+ # serializes to ~1-2 MB; passing that on the curl ARGV (`--data "$body"`)
223
+ # overflowed execve (E2BIG) and aborted the whole flush under `set -e`
224
+ # (dogfood incident 2026-06-11). `--data-binary @<file>` streams the bytes
225
+ # verbatim off-argv, so body size is bounded only by control's 10mb body
226
+ # limit, never by ARG_MAX. -binary (not plain --data) so curl does not strip
227
+ # newlines/CRs from the file.
228
+ local method="$1" url="$2" body_file="$3"
229
+ HTTP_CODE=000
230
+ CURL_RC=0
231
+ HTTP_CODE="$(curl -fsS --max-time 5 -o /dev/null -w '%{http_code}' \
232
+ -X "$method" "$url" \
233
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
234
+ "${ACTOR_HEADER[@]+"${ACTOR_HEADER[@]}"}" \
235
+ --data-binary @"$body_file" 2>/dev/null)" || CURL_RC=$?
236
+ if [[ "$CURL_RC" -ne 0 && "$HTTP_CODE" == "401" && "$AUTH_MODE" == "user-token" ]]; then
237
+ local refresh_rc=0
238
+ refresh_user_token || refresh_rc=$?
239
+ if [[ "$refresh_rc" -eq 0 ]]; then
240
+ TOKEN="$(jq -r '.auth.accessToken // .controlToken // empty' "$CFG" 2>/dev/null || true)"
241
+ log "flush: control 401 on $method; refreshed access token, retrying once"
242
+ HTTP_CODE=000
243
+ CURL_RC=0
244
+ HTTP_CODE="$(curl -fsS --max-time 5 -o /dev/null -w '%{http_code}' \
245
+ -X "$method" "$url" \
246
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
247
+ "${ACTOR_HEADER[@]+"${ACTOR_HEADER[@]}"}" \
248
+ --data-binary @"$body_file" 2>/dev/null)" || CURL_RC=$?
249
+ else
250
+ log "flush: control 401 on $method; refresh did not rotate a token (rc=$refresh_rc); failing soft"
251
+ fi
252
+ fi
253
+ }
254
+
255
+ # Pass 1: session_started -> create AgentRun. There is normally exactly one
256
+ # such line per session; if multiple slip in (re-attached session), the
257
+ # server's upsert on (workspaceId, adapter, externalSessionId) keeps it
258
+ # idempotent so re-POST is safe.
259
+ while IFS= read -r LINE || [[ -n "$LINE" ]]; do
260
+ [[ -z "$LINE" ]] && continue
261
+ # Wedge v6 Epoch 26: tolerate malformed lines. Pre-fix the unguarded
262
+ # `EVT="$(... | jq -r '.event')"` propagated jq's non-zero exit through
263
+ # `set -euo pipefail`, crashing the whole flush. The .draining.$$ snapshot
264
+ # would be stranded; the next flush's orphan recovery re-cats the same
265
+ # bad line and crashes again -- infinite reflush loop on one corrupt
266
+ # write. The `|| echo ""` inside the subshell pins the failure inside the
267
+ # subshell so EVT becomes "" and the loop continues.
268
+ EVT="$(printf '%s' "$LINE" | jq -r '.event' 2>/dev/null || echo "")"
269
+ if [[ "$EVT" != "session_started" ]]; then
270
+ continue
271
+ fi
272
+ BODY="$(printf '%s' "$LINE" | jq -c --arg ws "$WORKSPACE_ID" \
273
+ '{workspaceId: $ws,
274
+ externalSessionId: .sessionId,
275
+ adapter: (.payload.adapter // "claude_code"),
276
+ repoPath: (.payload.repoPath // ""),
277
+ branch: (.payload.branch // null),
278
+ startedAt: .ts}
279
+ | with_entries(select(.value != null))' 2>/dev/null || echo "")"
280
+ if [[ -z "$BODY" ]]; then
281
+ continue
282
+ fi
283
+ # Stream the body from a file (printf is a shell builtin, no argv limit) so
284
+ # the transport is uniform with Pass 2 and never grows onto curl's ARGV. Use
285
+ # mktemp (system tmp), NOT a "$TMP.*" name: $TMP is "$QUEUE_FILE.draining.$$",
286
+ # so any "$TMP.*" child matches the orphan-recovery glob ("*.jsonl.draining.*")
287
+ # and would be cat'd back into the queue as a bogus event line.
288
+ P1_BODY_FILE="$(mktemp "${TMPDIR:-/tmp}/mla-p1body.XXXXXX")"
289
+ printf '%s' "$BODY" > "$P1_BODY_FILE"
290
+ control_capture_curl POST "$CONTROL_URL/internal/v1/agent-runs" "$P1_BODY_FILE"
291
+ rm -f "$P1_BODY_FILE"
292
+ if [[ "$CURL_RC" -eq 0 ]]; then
293
+ log "Pass 1: created/updated agent run (POST /internal/v1/agent-runs)"
294
+ else
295
+ case "$HTTP_CODE" in
296
+ 401|403|404) warn_capture_auth "$SESSION_ID" "$HTTP_CODE" "POST /internal/v1/agent-runs" ;;
297
+ esac
298
+ log "Pass 1: POST /internal/v1/agent-runs FAILED (HTTP $HTTP_CODE; control unreachable or 4xx/5xx); re-spooled session_started for retry"
299
+ spool_append "$SESSION_ID" "$LINE"
300
+ continue
301
+ fi
302
+ done < "$TMP"
303
+
304
+ # Pass 2: collect every non-session_started event-bearing line into a single
305
+ # events[] array and PATCH once. eventKey on each line dedupes server-side, so
306
+ # retries are safe. Empty events array short-circuits the PATCH.
307
+ #
308
+ # Wedge v6 Epoch 25: the filter lives in `event-batch-filter.jq` next to this
309
+ # script so a corrupt-line tolerance contract can be unit-tested. Pre-fix
310
+ # `jq -s` failed the whole batch on ONE malformed line and `|| echo "[]"`
311
+ # silently dropped every valid event with it. The new filter uses
312
+ # `-R -s` + `fromjson?` to skip just the bad line.
313
+ #
314
+ # Wedge v6 Epoch 32: distinguish "filter ran and returned []" (genuinely
315
+ # empty batch, OK to short-circuit) from "filter file missing OR jq crashed"
316
+ # (we have no visibility into the snapshot and MUST NOT let Pass 3 burn the
317
+ # `agent_run_finalized:<runId>` outbox idempotency key on an empty event
318
+ # set). Pre-Epoch-32 the `|| echo "[]"` fallback collapsed both into
319
+ # EVENTS_OK=1 + finalize-session firing on a Run Ledger with no bash events
320
+ # and no agentClaimsRaw. Subsequent flushes (after re-installing the filter)
321
+ # would have nothing to re-deliver: dedupe wins, worker synthesizes a blank
322
+ # review packet, total silent loss. Now: filter failure -> EVENTS_OK=0 ->
323
+ # re-spool block below replays the events AND Pass 3 re-spools the finalize
324
+ # instead of firing it.
325
+ # Wedge v6 (dogfood incident 2026-06-11): build the batch entirely through
326
+ # files, never shell-variable-to-argv. A busy session's events[] is multi-MB;
327
+ # the pre-fix `--argjson events "$EVENTS_JSON"` put that array on jq's ARGV and
328
+ # overflowed execve (E2BIG), aborting the flush under `set -e` before any curl.
329
+ # The filter output goes straight to $EVENTS_FILE; the request body is assembled
330
+ # with `--slurpfile` (jq reads the file, nothing on argv); curl streams the body
331
+ # file. EVENTS_OK semantics (missing filter / jq crash -> defer + re-spool) are
332
+ # preserved exactly.
333
+ EVENT_FILTER="$(dirname "$0")/event-batch-filter.jq"
334
+ EVENTS_FILE=""
335
+ EVENT_COUNT=0
336
+ if [[ ! -f "$EVENT_FILTER" ]]; then
337
+ EVENTS_OK=0
338
+ log "Pass 2: event-batch-filter.jq MISSING; deferring events + finalize (run mla init to repair hooks)"
339
+ else
340
+ # mktemp (system tmp), NOT "$TMP.*": see the Pass 1 note. The filter output
341
+ # streams straight to the file; the array never touches a shell var or argv.
342
+ EVENTS_FILE="$(mktemp "${TMPDIR:-/tmp}/mla-events.XXXXXX")"
343
+ if ! jq -c -R -s -f "$EVENT_FILTER" < "$TMP" > "$EVENTS_FILE" 2>/dev/null; then
344
+ EVENTS_OK=0
345
+ log "Pass 2: jq event filter crashed; deferring events + finalize"
346
+ else
347
+ EVENT_COUNT="$(jq 'length' < "$EVENTS_FILE" 2>/dev/null || echo 0)"
348
+ fi
349
+ fi
350
+
351
+ if [[ "${EVENT_COUNT:-0}" -gt 0 ]]; then
352
+ # --slurpfile wraps the file's single JSON array value as $evs[0]. No argv
353
+ # carries the payload, so this is overflow-proof regardless of batch size.
354
+ PATCH_BODY_FILE="$(mktemp "${TMPDIR:-/tmp}/mla-body.XXXXXX")"
355
+ jq -c -n --arg ws "$WORKSPACE_ID" --slurpfile evs "$EVENTS_FILE" \
356
+ '{workspaceId: $ws, events: $evs[0]}' > "$PATCH_BODY_FILE" 2>/dev/null
357
+ control_capture_curl PATCH \
358
+ "$CONTROL_URL/internal/v1/agent-runs/by-session/$SESSION_ID/events" "$PATCH_BODY_FILE"
359
+ rm -f "$PATCH_BODY_FILE"
360
+ if [[ "$CURL_RC" -eq 0 ]]; then
361
+ log "Pass 2: PATCHed $EVENT_COUNT event(s) -> /by-session/$SESSION_ID/events"
362
+ else
363
+ # Non-2xx (control down, transient network, HTTP 4xx/5xx). Server dedupes on
364
+ # eventKey, so the re-spool block below replays the lot. 401/403/404 also fire
365
+ # a throttled local warning (fail soft); other codes just re-spool silently.
366
+ case "$HTTP_CODE" in
367
+ 401|403|404) warn_capture_auth "$SESSION_ID" "$HTTP_CODE" "PATCH /internal/v1/agent-runs/by-session/$SESSION_ID/events" ;;
368
+ esac
369
+ EVENTS_OK=0
370
+ log "Pass 2: PATCH events FAILED (HTTP $HTTP_CODE; control unreachable or 4xx/5xx); will re-spool $EVENT_COUNT event(s)"
371
+ fi
372
+ fi
373
+
374
+ # The events scratch file has served its purpose (body already built and sent).
375
+ # The re-spool path below reads from $TMP, never from EVENTS_FILE. Explicit `if`,
376
+ # not `[[ ]] && rm`, so a falsy guard does not trip `set -e`.
377
+ if [[ -n "$EVENTS_FILE" ]]; then
378
+ rm -f "$EVENTS_FILE"
379
+ fi
380
+
381
+ # Wedge v6 Epoch 32: re-spool every event-bearing line on ANY Pass 2 failure
382
+ # (filter file missing, jq crashed, OR PATCH failed). One code path serves
383
+ # all three failure modes; pre-Epoch-32 only the PATCH-failure path replayed,
384
+ # so a missing filter stranded the batch and let finalize ship empty.
385
+ if [[ "$EVENTS_OK" == "0" ]]; then
386
+ log "re-spooling event-bearing line(s) for the next flush"
387
+ while IFS= read -r LINE || [[ -n "$LINE" ]]; do
388
+ [[ -z "$LINE" ]] && continue
389
+ # Wedge v6 Epoch 26: same tolerance as Pass 1. A malformed line in the
390
+ # snapshot would crash this re-spool loop under `set -e` and strand the
391
+ # batch in .draining.$$ permanently.
392
+ EVT="$(printf '%s' "$LINE" | jq -r '.event' 2>/dev/null || echo "")"
393
+ case "$EVT" in
394
+ prompt_submitted|tool_used_bash|tool_used_file|session_stopped|agent_decision_captured|injection_trace|assistant_message)
395
+ spool_append "$SESSION_ID" "$LINE"
396
+ ;;
397
+ esac
398
+ done < "$TMP"
399
+ fi
400
+
401
+ # Pass 3: detect finalize_requested. Always last because finalize triggers the
402
+ # review packet pipeline; everything else must be persisted server-side first.
403
+ # If Pass 2 failed (EVENTS_OK=0), DO NOT fire finalize now -- the worker would
404
+ # build a Run Ledger from a partially-persisted event set (e.g. missing
405
+ # session_stopped -> agentClaimsRaw=null), and the outbox idempotencyKey
406
+ # `agent_run_finalized:<runId>` is unique, so a later retry would silently
407
+ # dedupe instead of re-synthesizing. Re-spool finalize_requested so the next
408
+ # flush (after events land) fires it cleanly.
409
+ if grep -q '"event":"finalize_requested"' "$TMP" 2>/dev/null; then
410
+ if [[ "$EVENTS_OK" == "0" ]]; then
411
+ log "Pass 3: finalize_requested DEFERRED (events not yet persisted); re-spooled for next flush"
412
+ FALLBACK_KEY="$(gen_event_key)"
413
+ spool_append "$SESSION_ID" "$(jq -c -n --arg sessionId "$SESSION_ID" --arg key "$FALLBACK_KEY" \
414
+ '{event:"finalize_requested", eventKey:$key, sessionId:$sessionId, payload:{}}')"
415
+ else
416
+ HAS_FINALIZE=1
417
+ fi
418
+ fi
419
+
420
+ if [[ "$HAS_FINALIZE" == "1" ]]; then
421
+ # Correction 6: finalize takes ONLY sessionId. finalMessage is on the
422
+ # persisted session_stopped event. Correction 7: absolute MLA_PATH.
423
+ #
424
+ # Wedge v6 Epoch 35: export MEETLESS_REPO_PATH from the session-start
425
+ # sidecar BEFORE invoking finalize. flush.sh is `nohup`-spawned, so its
426
+ # cwd is whatever nohup ran in (often $HOME). The CLI's Epoch 33 guard
427
+ # refuses to POST when captureGitEvidence(cwd) returns empty topLevel,
428
+ # so without this export every finalize attempt re-spools and the next
429
+ # flush re-fails the same way -- permanent stuck-loss. The sidecar holds
430
+ # the repo path Claude Code fired the SessionStart hook with (the real
431
+ # project root). Missing sidecar is tolerated; CLI then falls back to
432
+ # process.cwd() and likely refuses, which is the correct "loud" signal.
433
+ REPO_SIDECAR="$QUEUE_DIR/$SESSION_ID.repoPath"
434
+ if [[ -s "$REPO_SIDECAR" ]]; then
435
+ export MEETLESS_REPO_PATH="$(cat "$REPO_SIDECAR")"
436
+ fi
437
+ if [[ -z "${MLA_PATH:-}" || ! -x "$MLA_PATH" ]]; then
438
+ log "Pass 3: finalize SKIPPED (mla CLI not executable at MLA_PATH); re-spooled finalize_requested"
439
+ FALLBACK_KEY="$(gen_event_key)"
440
+ spool_append "$SESSION_ID" "$(jq -c -n --arg sessionId "$SESSION_ID" --arg key "$FALLBACK_KEY" \
441
+ '{event:"finalize_requested", eventKey:$key, sessionId:$sessionId, payload:{}}')"
442
+ else
443
+ log "Pass 3: finalizing session (mla _internal finalize-session) -> triggers review packet pipeline"
444
+ if "$MLA_PATH" _internal finalize-session "$SESSION_ID"; then
445
+ log "Pass 3: finalize OK; review packet pipeline triggered. Inspect: mla review (inside session $SESSION_ID) | raw turns: mla session show $SESSION_ID"
446
+ # Successful finalize: drop the sidecars so a future stray flush
447
+ # for this sessionId doesn't keep exporting a stale path or
448
+ # subtracting a stale baseline. Preserved on failure below so a
449
+ # retry still attributes only session-touched changes.
450
+ rm -f "$REPO_SIDECAR"
451
+ rm -f "$QUEUE_DIR/$SESSION_ID.gitBaseline"
452
+ rm -f "$QUEUE_DIR/$SESSION_ID.workspaceId"
453
+ FINALIZE_OK=1
454
+ else
455
+ log "Pass 3: finalize FAILED; re-spooled finalize_requested for next flush"
456
+ FALLBACK_KEY="$(gen_event_key)"
457
+ spool_append "$SESSION_ID" "$(jq -c -n --arg sessionId "$SESSION_ID" --arg key "$FALLBACK_KEY" \
458
+ '{event:"finalize_requested", eventKey:$key, sessionId:$sessionId, payload:{}}')"
459
+ fi
460
+ fi
461
+ fi
462
+
463
+ log "flush complete"
464
+ rm -f "$TMP"
465
+
466
+ # RC1 (self-clean): a cleanly-drained session leaves QUEUE_FILE as the 0-byte
467
+ # file we truncated at detach time. Pre-fix it was removed ONLY at the top of a
468
+ # SUBSEQUENT flush (the "nothing to flush" branch above) -- but stop.sh fires
469
+ # the LAST flush of an ended session and no subsequent flush ever comes, so the
470
+ # empty spool lingered and queueDepth() counted it as an "active session"
471
+ # forever (the phantom "N active sessions" mla doctor reported). Remove it here,
472
+ # under the SAME lock spool_append uses, but ONLY when it is still empty: Pass
473
+ # 1/2/3 re-spool on failure and a concurrent next-turn spool_append may have
474
+ # appended, both of which make it non-empty and MUST be preserved. spool_append
475
+ # recreates it with `>>` next turn, so removal is safe for a live session too.
476
+ # Non-blocking acquire: if a writer or another flush holds the lock we skip and
477
+ # the next flush's top-of-flush empty check is the backstop. The .lock/.turn
478
+ # sidecars are left for the age-gated `mla flush --gc` reaper (reapQueue), since
479
+ # a live multi-turn session still needs its turn counter.
480
+ exec 9>"$LOCK"
481
+ if flock -n 9; then
482
+ [[ -s "$QUEUE_FILE" ]] || rm -f "$QUEUE_FILE"
483
+ exec 9>&-
484
+ fi
485
+
486
+ # RC2 (finalize-time full reap): a cleanly-finalized session is OVER -- stop.sh
487
+ # fires its last flush and no later flush ever comes. The self-clean above only
488
+ # removes the empty spool, and the Pass 3 OK branch only the .repoPath/
489
+ # .gitBaseline/.workspaceId sidecars; the .turn (turn counter), .lock, .hb +
490
+ # .hb.lock (heartbeat) and .narration-cursor + .narration-cursor.lock sidecars
491
+ # were left for the 24h age-gated reaper -- 6 stranded files per ended session.
492
+ # Reclaim them now, but ONLY when the spool actually drained empty: a re-spooled
493
+ # finalize/retry tail (QUEUE_FILE still present and non-empty) means the session
494
+ # is not truly done, so we keep everything for the next flush. The fd-9 lock was
495
+ # already released above; a finalized session has no concurrent writer.
496
+ if [[ "$FINALIZE_OK" == "1" && ! -s "$QUEUE_FILE" ]]; then
497
+ rm -f "$QUEUE_DIR/$SESSION_ID.turn"
498
+ rm -f "$QUEUE_DIR/$SESSION_ID.hb" "$QUEUE_DIR/$SESSION_ID.hb.lock"
499
+ rm -f "$QUEUE_DIR/$SESSION_ID.narration-cursor" "$QUEUE_DIR/$SESSION_ID.narration-cursor.lock"
500
+ rm -f "$LOCK"
501
+ fi
502
+
503
+ exit 0