@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,934 @@
1
+ #!/usr/bin/env bash
2
+ # common.sh
3
+ # Sourced by every Meetless hook. Sets QUEUE_DIR, CFG, MLA_PATH; exposes
4
+ # gen_event_key + spool_append (locked) + spawn_flush.
5
+ #
6
+ # Source: notes/20260527-bare-bones-mvp-codebase-evaluation-and-plan.md §5.2.
7
+ set -euo pipefail
8
+
9
+ MEETLESS_HOME_DIR="${MEETLESS_HOME:-$HOME/.meetless}"
10
+ QUEUE_DIR="$MEETLESS_HOME_DIR/queue"
11
+ LOG_DIR="$MEETLESS_HOME_DIR/logs"
12
+ CFG="$MEETLESS_HOME_DIR/cli-config.json"
13
+ # Per-session OFF overrides. `mla mute` drops a `<sid>.off` sentinel here (cleared
14
+ # by `mla unmute`) to silence ONE live session even inside an activated folder
15
+ # (dogfooding A/B: run the same repo with capture+Push on in one session, off in
16
+ # another). This is the per-session CAPTURE lifecycle and is distinct from the
17
+ # folder's workspace BINDING, which `mla activate` / `mla deactivate` manage via
18
+ # the `.meetless.json` marker (muting never removes the marker).
19
+ SESSION_GATE_DIR="$MEETLESS_HOME_DIR/session-gate"
20
+ mkdir -p "$QUEUE_DIR"
21
+ mkdir -p "$LOG_DIR" 2>/dev/null || true
22
+
23
+ # Meetless-branded observability log. The hook pipeline is otherwise a black
24
+ # box (spawn_flush detaches flush.sh to a background process), so without this
25
+ # there is no way to watch the spool -> control -> finalize hops live. Every
26
+ # line is prefixed `[Meetless]` so it is unmistakable in a shared terminal.
27
+ # Writes to both a per-session file and a combined flush.log so a single
28
+ # `tail -f ~/.meetless/logs/flush.log` follows every session. When stderr is a
29
+ # TTY (interactive `mla flush`) it also echoes inline. Default-on; opt out with
30
+ # MEETLESS_DEBUG=0. Always returns 0 so it is safe under `set -euo pipefail`.
31
+ log() {
32
+ if [[ "${MEETLESS_DEBUG:-1}" == "0" ]]; then return 0; fi
33
+ local sid="${SESSION_ID:-unknown}"
34
+ local short="${sid:0:8}"
35
+ local line
36
+ line="[Meetless] $(date '+%H:%M:%S') flush[$short] $*"
37
+ printf '%s\n' "$line" >> "$LOG_DIR/flush-$sid.log" 2>/dev/null || true
38
+ printf '%s\n' "$line" >> "$LOG_DIR/flush.log" 2>/dev/null || true
39
+ if [[ -t 2 ]]; then printf '%s\n' "$line" >&2 || true; fi
40
+ return 0
41
+ }
42
+
43
+ # Path of the per-session throttle stamp for capture-auth warnings. Kept in
44
+ # LOG_DIR (not QUEUE_DIR) so the queue reaper never has to know about it and the
45
+ # spool sweep stays purely about queued events. Single argument: the session id.
46
+ capture_auth_warn_file() {
47
+ printf '%s/capture-auth-%s.warn' "$LOG_DIR" "$1"
48
+ }
49
+
50
+ # T1.5 fail-soft (folder = workspace, notes/20260604-folder-equals-workspace-
51
+ # binding-design.md "Hook failure behavior (fail soft)"): a capture write got an
52
+ # auth/visibility rejection (401 / 403 / 404). Capture is assistive and must
53
+ # NEVER break the session, so the detached flusher records a THROTTLED, human-
54
+ # readable local warning and keeps the queued events for a later retry. A 403
55
+ # here is usually the transient "committed marker, token not yet a workspace
56
+ # member" onboarding state, which clears the moment an owner adds you; warning on
57
+ # every turn would be noise, so we re-warn at most once per
58
+ # MEETLESS_AUTH_WARN_THROTTLE_SECS (default 3600), gated on a persisted timestamp
59
+ # so the throttle survives across the short-lived flusher processes. Warnings are
60
+ # appended to logs/capture-auth-warnings.log (and the live flush.log via log()).
61
+ # Args: <session-id> <http-code> <endpoint>. ALWAYS returns 0 (safe under set -e).
62
+ warn_capture_auth() {
63
+ local sid="$1" code="$2" endpoint="$3"
64
+ local throttle="${MEETLESS_AUTH_WARN_THROTTLE_SECS:-3600}"
65
+ local warn_file now last age
66
+ warn_file="$(capture_auth_warn_file "$sid")"
67
+ now="$(date +%s 2>/dev/null || echo 0)"
68
+ if [[ -f "$warn_file" ]]; then
69
+ last="$(head -n1 "$warn_file" 2>/dev/null || echo 0)"
70
+ [[ "$last" =~ ^[0-9]+$ ]] || last=0
71
+ age=$(( now - last ))
72
+ # Re-warned within the window: stay quiet this turn (but still fail-soft).
73
+ if (( age < throttle )); then return 0; fi
74
+ fi
75
+ printf '%s\n' "$now" > "$warn_file" 2>/dev/null || true
76
+
77
+ local ws="${WORKSPACE_ID:-}"
78
+ # Recovery for a 401 depends on HOW this CLI authenticated. A `user-token`
79
+ # session (browser OAuth via `mla login`) re-authenticates with `mla login`;
80
+ # telling it to run `mla init --control-token` is wrong twice over -- it points
81
+ # at the SHARED-KEY path, and readConfig() now hard-errors if a control token is
82
+ # layered over a logged-in session. A `shared-key` session (CI / headless) is
83
+ # correctly told to refresh that key. Unknown / no config falls back to the
84
+ # shared-key advice (the historical default). Read it fail-soft.
85
+ local auth_mode=""
86
+ auth_mode="$(jq -r '.auth.mode // empty' "$CFG" 2>/dev/null || true)"
87
+ local msg
88
+ case "$code" in
89
+ 401)
90
+ if [[ "$auth_mode" == "user-token" ]]; then
91
+ msg="capture paused: your Meetless login expired or was revoked (HTTP 401). Run \`mla login\` to re-authenticate. Queued events are kept and will retry."
92
+ else
93
+ msg="capture paused: control rejected the token (HTTP 401, invalid or expired). Run \`mla init --control-token <token>\` to refresh. Queued events are kept and will retry."
94
+ fi
95
+ ;;
96
+ 403)
97
+ # The guard 403s for two distinct reasons on a capture write, and they need
98
+ # different remedies. When flush.sh resolved no actor (ACTOR_USER_ID empty),
99
+ # it omitted the X-Meetless-Actor header and control rejected for missing
100
+ # actor identity (a client-side cli-config gap, NOT a membership gap). When
101
+ # an actor WAS sent, the 403 means that actor is not a provisioned member of
102
+ # the workspace. Blaming membership in the first case sends the operator
103
+ # chasing a ghost, so distinguish them.
104
+ if [[ -z "${ACTOR_USER_ID:-}" ]]; then
105
+ msg="capture paused: the CLI sent no actor identity for workspace ${ws:-<unknown>} (HTTP 403). Set actorUserId in ~/.meetless/cli-config.json (run \`mla init\` or \`mla activate\`). Queued events are kept and will retry."
106
+ else
107
+ msg="capture paused: actor ${ACTOR_USER_ID} is not a member of workspace ${ws:-<unknown>} (HTTP 403). Run \`mla activate\` (or ask a workspace owner to add you). Queued events are kept and will retry once you are a member."
108
+ fi
109
+ ;;
110
+ 404)
111
+ msg="capture paused: workspace ${ws:-<unknown>} was not found on control (HTTP 404). The marker may point at a deleted workspace; run \`mla doctor\` or \`mla activate --repair\`. Queued events are kept."
112
+ ;;
113
+ *)
114
+ msg="capture paused: control returned HTTP $code on $endpoint. Queued events are kept and will retry."
115
+ ;;
116
+ esac
117
+ log "WARN $msg"
118
+ printf '[Meetless] %s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo unknown)" "$msg" \
119
+ >> "$LOG_DIR/capture-auth-warnings.log" 2>/dev/null || true
120
+ return 0
121
+ }
122
+
123
+ # Correction 7: absolute path resolved at install time; mla in PATH is NOT relied on.
124
+ MLA_PATH="$(jq -r '.mlaPath // empty' "$CFG" 2>/dev/null || true)"
125
+ if [[ -z "${MLA_PATH:-}" || ! -x "$MLA_PATH" ]]; then
126
+ MLA_PATH="$(command -v mla 2>/dev/null || true)"
127
+ fi
128
+
129
+ # T1.2 hard cutover (folder = workspace): the marker is the ONLY source of the
130
+ # workspaceId. WORKSPACE_ID starts empty and is set by meetless_activated() from
131
+ # the resolved .meetless.json; the cli-config workspaceId is no longer read here.
132
+ # The four capture hooks call meetless_activated (which fills this in) before they
133
+ # spool, so it is populated by the time flush.sh wraps lines into Nest DTO shape
134
+ # ({workspaceId, ...}). The nohup-detached flusher cannot walk up to the marker
135
+ # (cwd=$HOME), so it sources WORKSPACE_ID from the per-session .workspaceId sidecar
136
+ # written at session start. Empty string => spool + skip rather than POST a 400.
137
+ WORKSPACE_ID=""
138
+
139
+ # Bash twin of canonicalizeSessionId (TS) / canonicalize_agent_session_id
140
+ # (Python). ONE shared grammar across all three languages so the same Claude
141
+ # session UUID never canonicalizes to two strings and splits the Langfuse
142
+ # Session. Pure: trim leading/trailing whitespace, match the canonical dashed
143
+ # UUID (case-insensitive, ANCHORED), lowercase; on no match print nothing (empty
144
+ # => "no agent session"). The anchored match is the header-injection guard: any
145
+ # newline, leftover whitespace, or stray byte after trim fails the match, so the
146
+ # value is safe to hand to a `curl -H` header (validate BEFORE -H, per the spec).
147
+ # The regex is stored in a var and referenced UNQUOTED so bash 3.2's `=~` treats
148
+ # it as a pattern, not a literal.
149
+ canonicalize_agent_session_id() {
150
+ local raw="${1:-}"
151
+ raw="${raw#"${raw%%[![:space:]]*}"}"
152
+ raw="${raw%"${raw##*[![:space:]]}"}"
153
+ local re='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
154
+ if [[ "$raw" =~ $re ]]; then
155
+ printf '%s' "$raw" | tr '[:upper:]' '[:lower:]'
156
+ fi
157
+ }
158
+
159
+ # Smaller-B: uuidgen preferred, openssl rand -hex 16 fallback. Stable per logical event.
160
+ gen_event_key() {
161
+ if command -v uuidgen >/dev/null 2>&1; then
162
+ uuidgen
163
+ else
164
+ openssl rand -hex 16
165
+ fi
166
+ }
167
+
168
+ # Resolve the live session name from a Claude Code transcript so the console
169
+ # Sessions page shows the same name the operator sees in the local picker.
170
+ #
171
+ # The tool records the name on TWO line types and the picker shows them with a
172
+ # fixed precedence: a human title set via /title (`custom-title`) wins, and
173
+ # otherwise the auto-titler's name (`ai-title`) is shown. The auto title is the
174
+ # COMMON case -- most sessions are never manually renamed -- so grepping only
175
+ # `custom-title` (the historical behavior) left those sessions untitled in
176
+ # control, which then fell back to the raw first prompt or "Session <id>" and
177
+ # diverged from the picker. We mirror the picker: latest custom-title if any,
178
+ # else latest ai-title. Either grep scans only the small title lines (~5ms on a
179
+ # 6k-line transcript), well inside the <1s Stop budget. Fail-soft: a missing
180
+ # transcript or any error yields an empty title and control's last-write-wins,
181
+ # no-clobber-on-empty rule leaves any prior title untouched.
182
+ resolve_session_title() {
183
+ local transcript="$1"
184
+ [[ -n "$transcript" && -f "$transcript" ]] || { printf ''; return 0; }
185
+ local title=""
186
+ title="$(grep '"type":"custom-title"' "$transcript" 2>/dev/null \
187
+ | tail -n 1 \
188
+ | jq -r 'try (.customTitle // empty) catch empty' 2>/dev/null || true)"
189
+ if [[ -z "$title" ]]; then
190
+ title="$(grep '"type":"ai-title"' "$transcript" 2>/dev/null \
191
+ | tail -n 1 \
192
+ | jq -r 'try (.aiTitle // empty) catch empty' 2>/dev/null || true)"
193
+ fi
194
+ printf '%s' "$title"
195
+ }
196
+
197
+ # I1 (interception): best-effort snapshot of the files the agent is about to
198
+ # touch, sourced from the git working-tree delta at prompt-submit time. This is
199
+ # literally "the surfaces the agent is actually modifying" (spec §I1: enrich must
200
+ # seed retrieval from the touched-file SET, not from the prompt's phrasing), and
201
+ # it picks up Bash-driven edits too, which keeps us inside the v0 Bash-only
202
+ # capture boundary (Edit/Write tool I/O is out of scope until the rejected
203
+ # --unsafe-capture-non-bash ships in v0.1).
204
+ #
205
+ # Emits a compact JSON array of paths on stdout (e.g. ["a.ts","b.ts"]), deduped
206
+ # and bounded to MEETLESS_TOUCHED_FILES_MAX (default 50). ALWAYS returns 0 and
207
+ # prints "[]" on any failure (no git binary, not a repo, empty repo with no HEAD,
208
+ # detached worktree). An empty result is the compat-6.2 signal: callers OMIT the
209
+ # field entirely, so retrieval falls back to today's prompt-only behavior.
210
+ #
211
+ # Deliberately does NOT emit a structured proposed_action. At UserPromptSubmit
212
+ # there is no concrete pending action to describe; that field is reserved for a
213
+ # future PreToolUse interception surface. touched_files are ranking hints only
214
+ # (spec I-SEC-1) and never widen ACL (I-SEC-3); intel treats them as such.
215
+ collect_touched_files() {
216
+ local dir="${1:-$PWD}"
217
+ local max="${MEETLESS_TOUCHED_FILES_MAX:-50}"
218
+ command -v git >/dev/null 2>&1 || { printf '[]'; return 0; }
219
+ command -v jq >/dev/null 2>&1 || { printf '[]'; return 0; }
220
+ git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { printf '[]'; return 0; }
221
+ # Tracked changes vs HEAD (staged + unstaged) plus untracked-but-not-ignored
222
+ # files. Two clean newline-separated path lists, no porcelain status prefix and
223
+ # no rename arrows to parse. Each command is independently best-effort: a fresh
224
+ # repo with no HEAD makes `diff HEAD` fail, but ls-files still contributes.
225
+ local files
226
+ files="$(
227
+ {
228
+ git -C "$dir" diff --name-only HEAD 2>/dev/null
229
+ git -C "$dir" ls-files --others --exclude-standard 2>/dev/null
230
+ } | awk 'NF' | sort -u | head -n "$max"
231
+ )"
232
+ [[ -z "$files" ]] && { printf '[]'; return 0; }
233
+ printf '%s' "$files" | jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null || printf '[]'
234
+ return 0
235
+ }
236
+
237
+ # Per-folder activation gate (opt-in). Modeled on how Claude Code discovers
238
+ # CLAUDE.md: walk UP from the start dir (default $PWD) looking for the nearest
239
+ # `.meetless.json` marker, nearest-wins. A session is captured ONLY when a
240
+ # marker is found. Without one, the capture hooks `exit 0` before spooling, so
241
+ # Meetless stays dormant in every folder the operator has not explicitly
242
+ # activated with `mla activate`.
243
+ #
244
+ # CALLED ONLY by the four CAPTURE hooks (session-start, user-prompt-submit,
245
+ # post-tool-use, stop), which Claude Code fires with cwd = the session's launch
246
+ # dir. It MUST NOT be called from flush.sh: the flusher is nohup-detached and
247
+ # inherits cwd=$HOME, so a walk-up there would always miss the repo marker and
248
+ # wrongly suppress an already-queued session.
249
+ #
250
+ # On success: returns 0 and sets MEETLESS_MARKER_FILE (absolute path) plus
251
+ # MEETLESS_MARKER_WORKSPACE_ID (optional workspaceId parsed from the marker;
252
+ # empty when absent or unparseable). T1.2 cutover: it ALSO sets WORKSPACE_ID to
253
+ # the marker's workspaceId so the capture path POSTs under the marker id, never
254
+ # the cli-config one. On miss: returns 1 and leaves all three vars empty (no
255
+ # cli-config fallback), so the capturing hook exits 0 before spooling.
256
+ meetless_activated() {
257
+ local dir="${1:-$PWD}"
258
+ MEETLESS_MARKER_FILE=""
259
+ MEETLESS_MARKER_WORKSPACE_ID=""
260
+ WORKSPACE_ID=""
261
+ # Canonicalize so the walk terminates at "/" deterministically even when the
262
+ # hook was fired with a relative or symlinked cwd.
263
+ dir="$(cd "$dir" 2>/dev/null && pwd || true)"
264
+ [[ -z "$dir" ]] && return 1
265
+ while :; do
266
+ if [[ -f "$dir/.meetless.json" ]]; then
267
+ MEETLESS_MARKER_FILE="$dir/.meetless.json"
268
+ MEETLESS_MARKER_WORKSPACE_ID="$(jq -r '.workspaceId // empty' "$MEETLESS_MARKER_FILE" 2>/dev/null || true)"
269
+ WORKSPACE_ID="$MEETLESS_MARKER_WORKSPACE_ID"
270
+ return 0
271
+ fi
272
+ [[ "$dir" == "/" ]] && break
273
+ dir="$(dirname "$dir")"
274
+ done
275
+ return 1
276
+ }
277
+
278
+ # Per-session OFF override. Returns 0 (disabled) when a `<sid>.off` sentinel
279
+ # exists in SESSION_GATE_DIR, written by `mla mute` (cleared by `mla unmute`) for
280
+ # this exact live session. Lets the operator silence ONE session (capture AND
281
+ # Push) even inside an activated folder, without un-activating the folder for
282
+ # every other session. Distinct from `mla deactivate`, which removes the folder's
283
+ # `.meetless.json` binding for all sessions.
284
+ # Existence check only (no jq parse) so it stays cheap on the hook hot path.
285
+ #
286
+ # CALLED ONLY by the four CAPTURE hooks, and ONLY AFTER SESSION_ID has been
287
+ # parsed from stdin (the per-folder gate runs first, before stdin is read). A
288
+ # missing or empty sid is treated as "not disabled" (the empty-sid guard in each
289
+ # hook has already exited 0 by then).
290
+ meetless_session_disabled() {
291
+ local sid="$1"
292
+ [[ -n "$sid" && -f "$SESSION_GATE_DIR/$sid.off" ]]
293
+ }
294
+
295
+ # Correction 5: append-with-flock. ALL writers + flusher contend for the same
296
+ # lock file ($QUEUE_DIR/$SESSION_ID.lock).
297
+ spool_append() {
298
+ local session_id="$1"
299
+ local line="$2"
300
+ local lock="$QUEUE_DIR/$session_id.lock"
301
+ local queue="$QUEUE_DIR/$session_id.jsonl"
302
+ exec 9>"$lock"
303
+ flock 9
304
+ printf '%s\n' "$line" >> "$queue"
305
+ exec 9>&-
306
+ }
307
+
308
+ # Monotonic per-session turn counter. Returns (echoes) the next 1-based index
309
+ # for this session and persists it, under the SAME per-session lock spool_append
310
+ # uses so it cannot race a concurrent writer. user-prompt-submit.sh stamps the
311
+ # returned value as turn_index on the enrichment trace line, giving every trace
312
+ # a dense, ordered position within its session (turn 1, 2, 3...) without parsing
313
+ # timestamps. A corrupt or missing counter file is treated as 0 (next = 1).
314
+ next_turn_index() {
315
+ local session_id="$1"
316
+ local lock="$QUEUE_DIR/$session_id.lock"
317
+ local counter="$QUEUE_DIR/$session_id.turn"
318
+ local n
319
+ exec 9>"$lock"
320
+ flock 9
321
+ n="$(cat "$counter" 2>/dev/null || echo 0)"
322
+ [[ "$n" =~ ^[0-9]+$ ]] || n=0
323
+ n=$((n + 1))
324
+ printf '%s' "$n" > "$counter"
325
+ exec 9>&-
326
+ printf '%s' "$n"
327
+ }
328
+
329
+ # Read-only peek at the per-session turn counter. Echoes the CURRENT 1-based
330
+ # index without advancing it, under the same per-session lock so it never reads
331
+ # a half-written value. next_turn_index is bumped exactly once per
332
+ # UserPromptSubmit, so during a turn's tool calls the counter holds that turn's
333
+ # index; post-tool-use.sh uses this to attribute the agent's own MCP calls
334
+ # (mcp-calls.jsonl) to the turn we enriched, giving A1 its (session_id,
335
+ # turn_index) join key against ask-traces.jsonl. A corrupt or missing counter
336
+ # (no UserPromptSubmit seen yet) reads as 0.
337
+ current_turn_index() {
338
+ local session_id="$1"
339
+ local lock="$QUEUE_DIR/$session_id.lock"
340
+ local counter="$QUEUE_DIR/$session_id.turn"
341
+ local n
342
+ exec 9>"$lock"
343
+ flock 9
344
+ n="$(cat "$counter" 2>/dev/null || echo 0)"
345
+ [[ "$n" =~ ^[0-9]+$ ]] || n=0
346
+ exec 9>&-
347
+ printf '%s' "$n"
348
+ }
349
+
350
+ # Minimal NOT_RUN liveness trace, written at a deliberate early exit where mla did
351
+ # NOT run a real agent turn (today: a muted session, `mla mute`). The per-turn
352
+ # assist recap (turn-recap.ts) and `mla turn N` join on (session_id, turn_index);
353
+ # without this line a muted turn is an unexplained GAP, indistinguishable from a
354
+ # crash, a timeout, or the session simply ending. So we record exactly one line
355
+ # that says WHY mla was silent: it advances the per-session turn counter (the agent
356
+ # DID take this turn) and stamps not_run_reason + injected=false, with NO prompt
357
+ # body. The line is LOCAL-only (never spooled, never forwarded to control/intel) and
358
+ # shares write_trace's ask-traces.lock so it can never interleave with a full trace.
359
+ # Fully fail-soft: every step is guarded and it always returns 0, so it can never
360
+ # block the prompt. Args: <session-id> <not_run_reason>, where reason is one of the
361
+ # NotRunReason enum (muted | not_activated | suppressed | timeout | error).
362
+ write_not_run_trace() {
363
+ local sid="$1" reason="$2"
364
+ [[ -n "$sid" && -n "$reason" ]] || return 0
365
+ local ts trace_id turn_index surface line
366
+ ts="$(date -u +%FT%TZ 2>/dev/null || printf '')"
367
+ trace_id="$(gen_event_key 2>/dev/null | tr -d '-' | tr 'A-F' 'a-f')" || trace_id=""
368
+ turn_index="$(next_turn_index "$sid" 2>/dev/null || printf 0)"
369
+ [[ "$turn_index" =~ ^[0-9]+$ ]] || turn_index=0
370
+ surface="${MEETLESS_INTERCEPT_SURFACE:-cli_intercept}"
371
+ line="$(jq -c -n \
372
+ --arg trace_id "$trace_id" \
373
+ --arg ts "$ts" \
374
+ --arg surface "$surface" \
375
+ --arg session_id "$sid" \
376
+ --argjson turn_index "$turn_index" \
377
+ --arg workspace_id "${WORKSPACE_ID:-}" \
378
+ --arg reason "$reason" \
379
+ '{
380
+ trace_id: $trace_id, ts: $ts, surface: $surface, mode: "not_run",
381
+ session_id: $session_id, turn_index: $turn_index,
382
+ workspace_id: $workspace_id,
383
+ input: null, enrichment: null,
384
+ hook: {injected: false, layer2_injected: false, not_run_reason: $reason},
385
+ error: null
386
+ }' 2>/dev/null || printf '')"
387
+ [[ -n "$line" ]] || return 0
388
+ (
389
+ flock 8
390
+ printf '%s\n' "$line" >> "$LOG_DIR/ask-traces.jsonl"
391
+ ) 8>"$LOG_DIR/ask-traces.lock" 2>/dev/null || true
392
+ return 0
393
+ }
394
+
395
+ # DUR (§5.4 DURING) coordination state. When the BEFORE-turn hook promotes an
396
+ # inject to an imperative coordination reminder it persists the validated triggers
397
+ # here, keyed on the turn index it just advanced. The PostToolUse hook reads the
398
+ # same path to raise a just-in-time flag when the agent edits a governed surface,
399
+ # and records flagged surfaces (one per line) so it never re-flags the same surface
400
+ # in a session. Co-located under logs/ so both hooks resolve the identical path.
401
+ coordination_dir() { printf '%s/coordination' "$LOG_DIR"; }
402
+ coordination_state_file() { printf '%s/coordination/%s.json' "$LOG_DIR" "$1"; }
403
+ coordination_flagged_file() { printf '%s/coordination/%s.flagged' "$LOG_DIR" "$1"; }
404
+
405
+ # A-0c (A4 surface 2) governance-nudge state. The pending-count cache is the
406
+ # out-of-band hand-off the `mla kb pending` CLI writes (it already knows the count
407
+ # from the list it just fetched) and the prompt-submit hook reads with NO network
408
+ # call (Patch 8: the count must not add a synchronous hot-path round trip). Keyed by
409
+ # workspace so a repointed home never reads a stale cross-workspace count; the CLI
410
+ # sanitizes the workspace id the SAME way (governance-cache.ts) so both sides
411
+ # resolve the identical filename. The inject-state is keyed by session so a fresh
412
+ # session re-shows the prose form once. Co-located under logs/ so they share the
413
+ # root the CLI computes from MEETLESS_HOME.
414
+ governance_dir() { printf '%s/governance' "$LOG_DIR"; }
415
+ governance_count_file() {
416
+ local ws_safe; ws_safe="$(printf '%s' "$1" | tr -c 'A-Za-z0-9_.-' '_')"
417
+ printf '%s/governance/pending-count-%s.json' "$LOG_DIR" "$ws_safe"
418
+ }
419
+ governance_inject_file() { printf '%s/governance/inject-%s.json' "$LOG_DIR" "$1"; }
420
+
421
+ # Cross-session steer transport (Plan 1, conflict-resolution loop). The cache is
422
+ # the out-of-band hand-off `mla _internal steer-sync` writes (pulled steers) and
423
+ # the prompt-submit hook reads with NO network call. The inject-state is written
424
+ # by the hook (the steer ids it injected, one session) and read back by steer-sync
425
+ # to mark them injected. Both keyed by session id (opaque CLAUDE_CODE_SESSION_ID, used
426
+ # verbatim like governance_inject_file). Co-located under logs/ so the CLI
427
+ # (steer-cache.ts) and these resolve the identical paths under MEETLESS_HOME.
428
+ steer_dir() { printf '%s/steer' "$LOG_DIR"; }
429
+ steer_cache_file() { printf '%s/steer/steer-%s.json' "$LOG_DIR" "$1"; }
430
+ steer_inject_file() { printf '%s/steer/inject-%s.json' "$LOG_DIR" "$1"; }
431
+
432
+ # Regime-1 (first-run pack) per-session inject-state. The first-run block is a
433
+ # LARGE static grounding pack (confirmed-rules + stale signals) read from the scan
434
+ # cache. Without a gate it re-injects on every turn, bloating additionalContext past
435
+ # the harness inline cap (the agent then only sees a truncated preview) and burning
436
+ # tokens. The hook stores the content hash it last emitted here, keyed by session, so
437
+ # it injects once per session and re-injects ONLY when a rescan changes the cache.
438
+ # Keyed by the opaque session id, co-located under logs/ like the siblings above.
439
+ regime1_dir() { printf '%s/regime1' "$LOG_DIR"; }
440
+ regime1_inject_file() { printf '%s/regime1/inject-%s.json' "$LOG_DIR" "$1"; }
441
+
442
+ # The closed CoordinationTrigger enum (§5.4.1). Both hooks hard-filter to this set
443
+ # so a malformed or injected trigger type can never manufacture an escalation.
444
+ COORDINATION_TRIGGER_ENUM='["GOVERNED_SURFACE_TOUCHED","ACCEPTED_DECISION_APPLIES","OPEN_COORDINATION_CASE","OWNER_APPROVAL_REQUIRED","BLAST_RADIUS_EDGE","CONTRADICTION_RISK","SUPERSESSION_RISK"]'
445
+
446
+ # Shared citation / source_id extractor (P3). Pulls every evidence token out of
447
+ # arbitrary text and echoes them as a sorted, de-duplicated JSON array (never a
448
+ # bare value; no match -> []). The token grammar mirrors intel's
449
+ # citation_validator: DD / TH / NT (decision-diff / theme / note) plus the
450
+ # CC|PP|PT|RC|WA|AU|DM operation tokens. Both bracketed `[NT:id]` and bare
451
+ # `NT:id` forms match. Used by post-tool-use.sh (the source_ids the agent PULLED)
452
+ # and stop.sh (the source_ids the agent's final report CITED) so the pull side
453
+ # and the push-reference side share one grammar. The grep can match zero (rc 1
454
+ # under pipefail); `|| true` keeps that from aborting the caller's `set -e`.
455
+ extract_source_ids() {
456
+ local text="$1"
457
+ local ids
458
+ ids="$(printf '%s' "$text" \
459
+ | grep -oE '(DD|TH|NT|CC|PP|PT|RC|WA|AU|DM):[A-Za-z0-9_.-]+' \
460
+ | sort -u \
461
+ | jq -R -s -c 'split("\n") | map(select(length > 0))' || true)"
462
+ [[ -z "$ids" ]] && ids="[]"
463
+ printf '%s' "$ids"
464
+ }
465
+
466
+ # A5 relevance-persistence ("carry ONCE"). P2 verified the prior trace line is
467
+ # only written, never read into enrich at turn N+1, and that intel cannot read it
468
+ # (two-DSN; the trace lives on this machine). So the carry read is HOOK-SIDE,
469
+ # mirroring the P1/P3 local-first precedent (see
470
+ # notes/20260604-p2-prior-trace-read-verification.md).
471
+ #
472
+ # read_prior_carry_state echoes the carry state distilled from this session's
473
+ # immediately-prior ask-traces.jsonl line:
474
+ # {"prior_carry": {<source_id>: <carry_count>, ...}, "harmful": <bool>}
475
+ # prior_carry merges (a) what we INJECTED last turn at carry_count 0
476
+ # (enrichment.context_items[] with injected==true and a non-empty source_id) with
477
+ # (b) what we already CARRIED last turn at its stamped carry_count
478
+ # (carry_forward.carried[]); a carried entry wins over an injected one for the
479
+ # same source_id, so an item that was carried once reads back as carry_count 1 and
480
+ # the once-only decay drops it. harmful is true when the operator rated last turn
481
+ # harmful, which suppresses every carry regardless of relevance (§7.4 A5 case 3).
482
+ # Best-effort and lock-free: a tail-read may clip the final partial line, but
483
+ # fromjson? drops it and we take the latest COMPLETE line by turn_index. A missing
484
+ # file or no matching line reads as the empty, not-harmful state.
485
+ read_prior_carry_state() {
486
+ local session_id="$1"
487
+ local f="$LOG_DIR/ask-traces.jsonl"
488
+ local state
489
+ state="$(tail -n 500 "$f" 2>/dev/null \
490
+ | jq -R -s -c --arg sid "$session_id" '
491
+ ( split("\n")
492
+ | map(select(length > 0) | fromjson?)
493
+ | map(select(.session_id == $sid))
494
+ | sort_by(.turn_index // 0)
495
+ | last
496
+ ) as $prior
497
+ | if $prior == null then {prior_carry: {}, harmful: false}
498
+ else
499
+ ( ($prior.enrichment.context_items // [])
500
+ | map(select((.injected == true) and ((.source_id // "") != ""))
501
+ | {key: .source_id, value: 0})
502
+ | from_entries
503
+ ) as $inj
504
+ | ( ($prior.carry_forward.carried // [])
505
+ | map({key: .source_id, value: .carry_count})
506
+ | from_entries
507
+ ) as $car
508
+ | {prior_carry: ($inj + $car), harmful: ($prior.operator_label.harmful == true)}
509
+ end
510
+ ' 2>/dev/null || true)"
511
+ [[ -z "$state" ]] && state='{"prior_carry":{},"harmful":false}'
512
+ printf '%s' "$state"
513
+ }
514
+
515
+ # A5 carry computation (pure). Given the prior carry state (read_prior_carry_state
516
+ # output) and THIS turn's enrichment object, echoes the carry list as a JSON array
517
+ # [{"source_id": ..., "carry_count": 1}, ...]: the prior-injected, not-yet-carried
518
+ # (carry_count == 0), still-surfaced items, stamped carry_count 1. "Still
519
+ # surfaced" = present in this turn's enrichment.context_items with a source_id, so
520
+ # a topic shift (no overlap) carries nothing. A harmful prior turn carries nothing.
521
+ # Empty array on any error so the caller's set -e is never tripped.
522
+ compute_carry() {
523
+ local state="$1" enrichment="$2"
524
+ jq -c -n --argjson state "$state" --argjson enr "$enrichment" '
525
+ ($state.prior_carry // {}) as $pc
526
+ | (($state.harmful // false) == true) as $harm
527
+ | ( [ ($enr.context_items // [])[]
528
+ | select((.source_id // "") != "")
529
+ | .source_id ]
530
+ | unique ) as $cur
531
+ | [ $cur[]
532
+ | select(($pc[.] != null) and ($harm | not) and ($pc[.] == 0))
533
+ | {source_id: ., carry_count: 1} ]
534
+ ' 2>/dev/null || printf '[]'
535
+ }
536
+
537
+ # Detached background flush. Hook process exits immediately. When debug logging
538
+ # is on, the detached flush's stdout+stderr are appended to its per-session log
539
+ # so stray curl/jq errors and any `set -e` abort are captured alongside the
540
+ # branded log() lines (which go to the file directly, not via stdout).
541
+ spawn_flush() {
542
+ local session_id="$1"
543
+ if [[ "${MEETLESS_DEBUG:-1}" == "0" ]]; then
544
+ (nohup "$MEETLESS_HOME_DIR/hooks/flush.sh" "$session_id" >/dev/null 2>&1 &) >/dev/null 2>&1 || true
545
+ else
546
+ (nohup "$MEETLESS_HOME_DIR/hooks/flush.sh" "$session_id" >>"$LOG_DIR/flush-$session_id.log" 2>&1 &) >/dev/null 2>&1 || true
547
+ fi
548
+ }
549
+
550
+ # F3-B throttled mid-turn liveness heartbeat. PostToolUse spools tool events but
551
+ # historically never flushed them, so a long, tool-heavy turn (many tool calls
552
+ # spanning >5min between the prompt-submit flush and the Stop flush) left
553
+ # control's lastSeenAt pinned at turn start and deriveLiveness aged the session
554
+ # into IDLE while it was actively working. Calling this at the top of PostToolUse
555
+ # fires a detached flush at most once per MEETLESS_HEARTBEAT_THROTTLE_SECS
556
+ # (default 60) per session, draining the events already queued this turn so
557
+ # lastSeenAt keeps advancing. It spools NO new event -- a Read/Grep turn still
558
+ # spools nothing; this is purely a periodic drain of the existing spool. Throttle
559
+ # state is a per-session epoch sidecar ($QUEUE_DIR/<sid>.hb) guarded by the same
560
+ # fd-9 flock idiom spool_append uses, so concurrent fires cannot double-flush.
561
+ # Fail-soft and always returns 0 so it can never block the tool under `set -e`.
562
+ heartbeat_flush() {
563
+ local session_id="$1"
564
+ [[ -n "$session_id" ]] || return 0
565
+ local throttle="${MEETLESS_HEARTBEAT_THROTTLE_SECS:-60}"
566
+ [[ "$throttle" =~ ^[0-9]+$ ]] || throttle=60
567
+ local hb="$QUEUE_DIR/$session_id.hb"
568
+ local lock="$QUEUE_DIR/$session_id.hb.lock"
569
+ local now last fire
570
+ now="$(date +%s 2>/dev/null || echo 0)"
571
+ [[ "$now" =~ ^[0-9]+$ ]] || now=0
572
+ fire=0
573
+ exec 9>"$lock"
574
+ flock 9
575
+ last="$(cat "$hb" 2>/dev/null || echo 0)"
576
+ [[ "$last" =~ ^[0-9]+$ ]] || last=0
577
+ if (( now - last >= throttle )); then
578
+ printf '%s' "$now" > "$hb"
579
+ fire=1
580
+ fi
581
+ exec 9>&-
582
+ if (( fire == 1 )); then
583
+ spawn_flush "$session_id"
584
+ fi
585
+ return 0
586
+ }
587
+
588
+ # ---- Active Review (Zone 1) helpers -------------------------------------
589
+ # Allowlist of prose extensions Zone 1 may capture; everything else (code,
590
+ # vendored trees, build output) is ignored. Denylist takes precedence.
591
+ # Spec tests 1 (code ignored) and 2 (node_modules ignored).
592
+ prose_path_allowed() {
593
+ local p="$1"
594
+ case "$p" in
595
+ */node_modules/*|node_modules/*|*/.git/*|.git/*|*/dist/*|dist/*|*/build/*|build/*|*/.next/*|.next/*|*/vendor/*|vendor/*) return 1 ;;
596
+ esac
597
+ # Synthetic eval/fixture/testdata prose is corpus material, never knowledge.
598
+ # Dogfood incident 2026-06-10: authoring an eval corpus (evals/*/corpus/*.md)
599
+ # got every fixture captured as a produced_doc and auto-indexed into the
600
+ # owner's Personal KB as SHADOW docs, minting bogus relationship candidates.
601
+ # Directory-segment match only, so a doc NAMED "...-eval-results.md" stays in.
602
+ case "$p" in
603
+ */evals/*|evals/*|*/fixtures/*|fixtures/*|*/__fixtures__/*|__fixtures__/*|*/testdata/*|testdata/*) return 1 ;;
604
+ esac
605
+ case "$p" in
606
+ *.md|*.markdown|*.mdx|*.rst|*.txt|*.adoc) return 0 ;;
607
+ *) return 1 ;;
608
+ esac
609
+ }
610
+
611
+ # A3 tagged_reference capture (Zone 1). Echoes the set of doc paths a user prompt
612
+ # NAMES, one per line, de-duplicated. Pure text scan: pulls every filename token
613
+ # ending in a prose extension (the same allowlist prose_path_allowed uses). This
614
+ # is the read side of A3: the UserPromptSubmit hook records each named path as a
615
+ # tagged_reference Active Memory record so Layer 3 can later join it against
616
+ # approved supersession/contradiction facts. The token grammar [A-Za-z0-9_./-]
617
+ # excludes quotes, backticks, and parentheses, so `old.md`, "old.md", and
618
+ # (old.md) all yield the clean token old.md without extra trimming. The grep can
619
+ # match zero (rc 1 under pipefail); `|| true` keeps that from aborting the caller.
620
+ extract_referenced_doc_paths() {
621
+ local text="$1"
622
+ printf '%s' "$text" \
623
+ | grep -oE '[A-Za-z0-9_./-]+\.(md|markdown|mdx|rst|txt|adoc)' \
624
+ | sort -u \
625
+ || true
626
+ }
627
+
628
+ # Stable hash of the repo root absolute path. Distinct roots -> distinct hashes,
629
+ # which keeps same-named docs in different repos from deduping (spec test 5).
630
+ repo_root_hash() {
631
+ printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
632
+ }
633
+
634
+ # Path relative to the repo root (portable; macOS lacks GNU realpath --relative-to).
635
+ canonical_path() {
636
+ local root="$1" abs="$2"
637
+ printf '%s' "${abs#"$root"/}"
638
+ }
639
+
640
+ # SHA-256 of the file's raw bytes; matches across identical content (spec test 4).
641
+ content_hash() {
642
+ shasum -a 256 "$1" 2>/dev/null | cut -d' ' -f1
643
+ }
644
+
645
+ # Echo the directory containing the nearest .meetless.json, walking up from $1.
646
+ meetless_repo_root() {
647
+ local dir="$1"
648
+ while [[ "$dir" != "/" && -n "$dir" ]]; do
649
+ [[ -f "$dir/.meetless.json" ]] && { printf '%s' "$dir"; return 0; }
650
+ dir="$(dirname "$dir")"
651
+ done
652
+ return 1
653
+ }
654
+
655
+ # Append one Active Review record (Zone 1). Pure local write under flock; never
656
+ # touches the network. Phase 0: this is the ONLY thing a produced-doc capture does.
657
+ # Args: kind sessionId turnIndex workspaceId ownerUserId repoRootHash canonicalPath contentHash [repoRoot]
658
+ # repoRoot (9th, optional) is the absolute repo root, stored LOCAL-only so the Zone 2
659
+ # auto-index can resolve the doc on disk (absPath = join(repoRoot, canonicalPath)). It
660
+ # is never transmitted (the detect wire sends only canonicalPath + kind + empty body).
661
+ # Optional under set -u because the tagged_reference caller passes only 8 args.
662
+ record_active_memory() {
663
+ local kind="$1" sid="$2" turn="$3" ws="$4" owner="$5" rrh="$6" cpath="$7" chash="$8"
664
+ local repoRoot="${9:-}"
665
+ local ts; ts="$(date -u +%FT%TZ)"
666
+ mkdir -p "$LOG_DIR"
667
+ local line
668
+ line="$(jq -c -n \
669
+ --arg ts "$ts" --arg event "active_memory_record" \
670
+ --arg ws "$ws" --arg owner "$owner" --arg rrh "$rrh" \
671
+ --arg cpath "$cpath" --arg chash "$chash" \
672
+ --arg sid "$sid" --argjson turn "$turn" \
673
+ --arg sp "claude_code" --arg kind "$kind" --arg createdAt "$ts" \
674
+ --arg repoRoot "$repoRoot" \
675
+ '{ts:$ts,event:$event,workspaceId:$ws,ownerUserId:$owner,repoRootHash:$rrh,canonicalPath:$cpath,contentHash:$chash,sessionId:$sid,turnIndex:$turn,sourceProduct:$sp,kind:$kind,createdAt:$createdAt,repoRoot:$repoRoot}')"
676
+ (
677
+ flock 9
678
+ printf '%s\n' "$line" >> "$LOG_DIR/kb-knowledge.jsonl"
679
+ ) 9>"$LOG_DIR/kb-knowledge.lock"
680
+ }
681
+
682
+ # Detached, age-gated stale-session GC. Runs `mla flush --reap-only` (reap
683
+ # WITHOUT draining) so a Stop hook can sweep dead-session litter
684
+ # (`.lock`/`.turn`/`.repoPath`/`.gitBaseline`/`.workspaceId` + 0-byte spools idle > 24h) without
685
+ # re-draining every active session -- the O(sessions) fan-out that left 99
686
+ # stranded locks. The reap is age-gated, so on a healthy box this is a cheap
687
+ # read-only dir scan that removes nothing. Fully detached + best-effort so it can
688
+ # never delay the hook (Stop's <1s budget) or fail it. No-op when the CLI cannot
689
+ # be located. Reuses MLA_PATH resolved above (config mlaPath, else `mla` in PATH).
690
+ spawn_reap() {
691
+ [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]] || return 0
692
+ if [[ "${MEETLESS_DEBUG:-1}" == "0" ]]; then
693
+ (nohup "$MLA_PATH" flush --reap-only --quiet >/dev/null 2>&1 &) >/dev/null 2>&1 || true
694
+ else
695
+ (nohup "$MLA_PATH" flush --reap-only >>"$LOG_DIR/reap.log" 2>&1 &) >/dev/null 2>&1 || true
696
+ fi
697
+ }
698
+
699
+ # ---- Zone 2 auto-index (Personal KB SHADOW ingest) ----------------------
700
+ # Default-on kill switch for the Zone 2 auto-index loop. Returns 0 (enabled)
701
+ # unless MEETLESS_AUTO_INDEX is explicitly "0". Kept as a pure predicate so the
702
+ # gate is unit-testable without spawning anything. dev-flags-default-on: on once
703
+ # built; one env var flips it off if it ever misbehaves in the field.
704
+ auto_index_enabled() {
705
+ [[ "${MEETLESS_AUTO_INDEX:-1}" != "0" ]]
706
+ }
707
+
708
+ # Detached, fail-soft Zone 2 auto-index. Reads THIS session's produced-doc
709
+ # captures from the Active Review spool and indexes each into the owner's
710
+ # Personal KB as a SHADOW / agent_distilled doc (`mla _internal auto-index`).
711
+ # SHADOW never grounds anyone (INV-GROUNDING-APPROVED), so unattended ingest is
712
+ # safe; the explicit human gate moves to `mla kb promote` (SHADOW -> LIVE). Fully
713
+ # detached + best-effort, so it can never delay Stop (<1s budget) or fail it.
714
+ # No-op when disabled via the kill switch or when the CLI cannot be located.
715
+ # Reuses MLA_PATH resolved above (config mlaPath, else `mla` in PATH).
716
+ spawn_auto_index() {
717
+ local session_id="$1"
718
+ auto_index_enabled || return 0
719
+ [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]] || return 0
720
+ if [[ "${MEETLESS_DEBUG:-1}" == "0" ]]; then
721
+ (nohup "$MLA_PATH" _internal auto-index --session "$session_id" >/dev/null 2>&1 &) >/dev/null 2>&1 || true
722
+ else
723
+ (nohup "$MLA_PATH" _internal auto-index --session "$session_id" >>"$LOG_DIR/auto-index-$session_id.log" 2>&1 &) >/dev/null 2>&1 || true
724
+ fi
725
+ }
726
+
727
+ # ---- Deleted-session reconcile (archive AgentRuns whose transcript is gone) ----
728
+ # Default-on kill switch for the deleted-session sweep. Returns 0 (enabled)
729
+ # unless MEETLESS_SESSION_RECONCILE is explicitly "0". Pure predicate so the gate
730
+ # is unit-testable without spawning. dev-flags-default-on: on once built; one env
731
+ # var flips it off if it ever misbehaves in the field.
732
+ session_reconcile_enabled() {
733
+ [[ "${MEETLESS_SESSION_RECONCILE:-1}" != "0" ]]
734
+ }
735
+
736
+ # Detached, fail-soft deleted-session reconcile. Claude Code has NO "session
737
+ # deleted" event, so the only way to notice a session was deleted is to compare
738
+ # the workspace's captured AgentRuns against the transcripts still present under
739
+ # ~/.claude/projects and archive the ones whose transcript is provably gone
740
+ # (`mla session reconcile`; the sweep itself is fail-SAFE, archiving only on
741
+ # positive proof of deletion). Fired on SessionStart as the natural throttling
742
+ # tick: an archived row drops out of the default list, so steady state is one
743
+ # cheap GET. Fully detached + best-effort so it can never delay or fail the hook.
744
+ # No-op when disabled via the kill switch or when the CLI cannot be located.
745
+ spawn_reconcile() {
746
+ session_reconcile_enabled || return 0
747
+ [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]] || return 0
748
+ if [[ "${MEETLESS_DEBUG:-1}" == "0" ]]; then
749
+ (nohup "$MLA_PATH" session reconcile >/dev/null 2>&1 &) >/dev/null 2>&1 || true
750
+ else
751
+ (nohup "$MLA_PATH" session reconcile >>"$LOG_DIR/session-reconcile.log" 2>&1 &) >/dev/null 2>&1 || true
752
+ fi
753
+ }
754
+
755
+ # ---- Evidence analytics (T4.1 inject / T4.2 outcome correlator) ----------
756
+ # Default-on kill switch for the evidence-analytics inject + correlate loop.
757
+ # Returns 0 (enabled) unless MEETLESS_EVIDENCE_ANALYTICS is explicitly "0". Pure
758
+ # predicate so the gate is unit-testable without spawning. dev-flags-default-on:
759
+ # on once built; one env var flips it off if it ever misbehaves in the field.
760
+ evidence_analytics_enabled() {
761
+ [[ "${MEETLESS_EVIDENCE_ANALYTICS:-1}" != "0" ]]
762
+ }
763
+
764
+ # Detached, fail-soft mla_evidence_inject record (spec T4.1). Fired from the
765
+ # UserPromptSubmit hook ONLY on a turn that actually pushed >= 1 evidence
766
+ # source_id (the SAME population parseInjectTurns scopes the adoption join to:
767
+ # enrichment.context_items[] with injected==true and a non-empty source_id), so
768
+ # the analytics inject denominator matches the followthrough join exactly. Records
769
+ # one local mla_evidence_inject line (inject_id + window_deadline) and best-effort
770
+ # forwards when telemetry is on. Fully detached + best-effort, so it never delays
771
+ # the hot path (UserPromptSubmit budget) or fails the prompt. No-op when disabled,
772
+ # when the CLI cannot be located, or when no offered ids were pushed.
773
+ # Args: turnIndex offeredIdsCsv tokens confidence latencyMs traceId workspaceId sessionId
774
+ spawn_evidence_inject() {
775
+ local turn="$1" ids="$2" tokens="$3" conf="$4" latency="$5" trace="$6" ws="$7" sid="$8"
776
+ evidence_analytics_enabled || return 0
777
+ [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]] || return 0
778
+ [[ -n "$ids" ]] || return 0 # no offered source_ids -> not an inject turn
779
+ if [[ "${MEETLESS_DEBUG:-1}" == "0" ]]; then
780
+ (nohup "$MLA_PATH" _internal evidence-inject \
781
+ --turn-index "$turn" --offered-ids "$ids" --tokens "$tokens" \
782
+ --confidence "$conf" --latency-ms "$latency" --trace-id "$trace" \
783
+ --workspace-id "$ws" --session-id "$sid" >/dev/null 2>&1 &) >/dev/null 2>&1 || true
784
+ else
785
+ (nohup "$MLA_PATH" _internal evidence-inject \
786
+ --turn-index "$turn" --offered-ids "$ids" --tokens "$tokens" \
787
+ --confidence "$conf" --latency-ms "$latency" --trace-id "$trace" \
788
+ --workspace-id "$ws" --session-id "$sid" >>"$LOG_DIR/evidence-inject-$sid.log" 2>&1 &) >/dev/null 2>&1 || true
789
+ fi
790
+ }
791
+
792
+ # Detached, fail-soft Stop-hook correlator (spec T4.2, INV-CORRELATOR-1). Closes
793
+ # every eligible PENDING inject window (3 turns or 15 minutes) across ALL sessions
794
+ # and appends one mla_evidence_outcome per closed inject to the local jsonl, then
795
+ # best-effort forwards when telemetry is on. It sweeps cross-session because a
796
+ # window can only close by time_limit minutes after the session ended, and a Stop
797
+ # is the natural recompute tick, so it takes NO session argument. Fully detached +
798
+ # best-effort + kill-switchable, so it never delays Stop (<1s budget) or fails it.
799
+ # No-op when disabled or when the CLI cannot be located.
800
+ spawn_evidence_correlate() {
801
+ evidence_analytics_enabled || return 0
802
+ [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]] || return 0
803
+ if [[ "${MEETLESS_DEBUG:-1}" == "0" ]]; then
804
+ (nohup "$MLA_PATH" _internal evidence-correlate >/dev/null 2>&1 &) >/dev/null 2>&1 || true
805
+ else
806
+ (nohup "$MLA_PATH" _internal evidence-correlate >>"$LOG_DIR/evidence-correlate.log" 2>&1 &) >/dev/null 2>&1 || true
807
+ fi
808
+ }
809
+
810
+ # ---- Layer D per-turn recap -> Langfuse emission -------------------------
811
+ # Default-on kill switch for the Layer D Langfuse emission ONLY. Returns 0
812
+ # (enabled) unless MEETLESS_TURN_RECAP_LANGFUSE is explicitly "off". This is a
813
+ # SEPARATE flag from MEETLESS_TURN_RECAP (which gates the Layer C-lite next-prompt
814
+ # injection in user-prompt-submit.sh): the two surfaces are independent, so you can
815
+ # keep the free Langfuse observability on while silencing the context injection, or
816
+ # vice versa. Pure predicate so the gate is unit-testable without spawning anything.
817
+ # See notes/20260609-mla-per-turn-assist-recap-plan.md §4.4.
818
+ turn_recap_langfuse_enabled() {
819
+ [[ "${MEETLESS_TURN_RECAP_LANGFUSE:-on}" != "off" ]]
820
+ }
821
+
822
+ # Detached, fail-soft Layer D emission. Posts the JUST-FINISHED turn's assist
823
+ # recap to intel (`mla _internal turn-recap --emit-langfuse`), which attaches the
824
+ # mla_ran / mla_assist Langfuse scores + the full recap as trace metadata to that
825
+ # turn's Langfuse trace (keyed on the per-turn $TRACE_ID intel adopts as the
826
+ # langfuse_trace_id). Routed through intel so the Langfuse keys stay out of the
827
+ # (soon-to-be-OSS) CLI. Fully detached + best-effort + kill-switchable
828
+ # (MEETLESS_TURN_RECAP_LANGFUSE=off, independent of the C-lite injection), so it can
829
+ # never delay Stop (<1s budget) or fail it. No-op when disabled, when the CLI
830
+ # cannot be located, or when no real turn ran (turn index not a positive integer;
831
+ # the `--turn` parser requires >= 1 anyway). Reuses MLA_PATH resolved above.
832
+ # Args: session_id turn_index
833
+ spawn_turn_recap_emit() {
834
+ local session_id="$1" turn="$2"
835
+ turn_recap_langfuse_enabled || return 0
836
+ [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]] || return 0
837
+ [[ -n "$session_id" ]] || return 0
838
+ [[ "$turn" =~ ^[0-9]+$ && "$turn" -ge 1 ]] || return 0
839
+ if [[ "${MEETLESS_DEBUG:-1}" == "0" ]]; then
840
+ (nohup "$MLA_PATH" _internal turn-recap --session "$session_id" --turn "$turn" --emit-langfuse >/dev/null 2>&1 &) >/dev/null 2>&1 || true
841
+ else
842
+ (nohup "$MLA_PATH" _internal turn-recap --session "$session_id" --turn "$turn" --emit-langfuse >>"$LOG_DIR/turn-recap-emit-$session_id.log" 2>&1 &) >/dev/null 2>&1 || true
843
+ fi
844
+ }
845
+
846
+ # ---- Reactive/proactive user-token refresh (Part 3) ----------------------
847
+ # See notes/20260611-mla-hook-token-autorefresh-proposal.md. Hook-triggered token
848
+ # refresh is UNCONDITIONAL: there is no kill switch. A logged-in user always wants
849
+ # an expired access token to self-heal, so gating it behind an env var only added
850
+ # branches and a way to silently break the feature. (The legacy
851
+ # MEETLESS_HOOK_AUTOREFRESH var is intentionally ignored.)
852
+
853
+ # SYNCHRONOUS, fail-soft trigger for the TS CLI's concurrency-safe refreshUserToken
854
+ # (`mla _internal refresh`). UNLIKE the detached spawn_* helpers above, this runs
855
+ # in the FOREGROUND because the caller branches on its exit code: the reactive
856
+ # 401-retry only re-runs the request when this returns 0 (token rotated). bash
857
+ # writes ZERO tokens; the TS CLI owns the sidecar lock, single-flight, and atomic
858
+ # writeConfig. Exit-code contract (kept in sync with commands/internal-refresh.ts):
859
+ # 0 refreshed (rotated, adopted a concurrent winner, or proactively still-fresh)
860
+ # 75 EX_TEMPFAIL: busy / transient; keep events queued, do NOT retry now
861
+ # 77 EX_NOPERM: refresh token dead server-side; surface `mla login`
862
+ # 64 EX_USAGE: wrong mode / unreadable config / bad args
863
+ # 70 NOT ATTEMPTED (local sentinel): the CLI could not be located.
864
+ # NOT a sysexits code the subcommand emits (it is EX_SOFTWARE, never returned
865
+ # by internal-refresh.ts), so callers can tell "we never tried" apart from
866
+ # "the subcommand ran and said X".
867
+ # set -e-safe: the one command that can exit non-zero uses `|| rc=$?`, so a caller
868
+ # running under `set -euo pipefail` (e.g. flush.sh) is never aborted by this helper
869
+ # even on a 75/77/64. Callers must still consume the return via `|| rc=$?`.
870
+ # Optional $1: seconds for the proactive `--if-expiring-within <secs>` gate. With
871
+ # no arg the flag is omitted (a plain reactive refresh). --quiet is always passed
872
+ # (defense in depth: the subcommand never prints a token, and we /dev/null it too).
873
+ refresh_user_token() {
874
+ [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]] || return 70
875
+ local rc=0
876
+ if [[ -n "${1:-}" ]]; then
877
+ "$MLA_PATH" _internal refresh --quiet --if-expiring-within "$1" >/dev/null 2>&1 || rc=$?
878
+ else
879
+ "$MLA_PATH" _internal refresh --quiet >/dev/null 2>&1 || rc=$?
880
+ fi
881
+ return "$rc"
882
+ }
883
+
884
+ # Best-effort ISO8601 -> epoch seconds, cross-platform (Linux GNU date + macOS
885
+ # BSD date). Prints the epoch on success and returns 0; prints nothing and
886
+ # returns 1 when the timestamp cannot be parsed. Tries GNU `date -d` first (a
887
+ # no-op-fail on BSD, where -d is the DST flag), then BSD `date -j -f` after
888
+ # normalizing away fractional seconds and a trailing Z. A timezone OFFSET form
889
+ # (`+00:00`) only parses on the GNU branch; on BSD it falls through to a parse
890
+ # failure, which the caller treats as fail-safe (spawn the TS gate) rather than a
891
+ # skip. Used by the proactive refresh gate below.
892
+ iso_to_epoch() {
893
+ local iso="$1" e=""
894
+ e="$(date -d "$iso" +%s 2>/dev/null || true)"
895
+ if [[ -n "$e" ]]; then printf '%s' "$e"; return 0; fi
896
+ local norm="${iso%.*}" # drop fractional seconds if present
897
+ norm="${norm%Z}" # drop trailing Z
898
+ e="$(date -j -u -f "%Y-%m-%dT%H:%M:%S" "$norm" +%s 2>/dev/null || true)"
899
+ if [[ -n "$e" ]]; then printf '%s' "$e"; return 0; fi
900
+ return 1
901
+ }
902
+
903
+ # Proactive "refresh-ahead" (Part 3 §A, Phase 2). Call BEFORE reading the enrich
904
+ # token so a near-expiry access token is rotated on disk first and Layer 2 uses a
905
+ # fresh token instead of taking a reactive 401. Cheap by design: a pure-bash
906
+ # freshness check skips the node spawn on the overwhelmingly common path (token
907
+ # comfortably fresh, > skew seconds of life left). It spawns the TS gate
908
+ # (`refresh_user_token <skew>` -> `mla _internal refresh --if-expiring-within`)
909
+ # ONLY when the token is within the skew window OR its timestamp cannot be parsed.
910
+ # The parse-failure branch is FAIL-SAFE: it spawns (the TS gate re-checks the same
911
+ # skew in well-tested Date logic and no-ops if actually fresh) rather than skip a
912
+ # refresh the session may need. Gated on user-token mode only. Best-effort: a
913
+ # non-zero refresh rc is NOT fatal here (the reactive 401 path remains the real
914
+ # safety net), so the call is `|| true` and this helper always returns 0. Skew
915
+ # override: MEETLESS_HOOK_REFRESH_SKEW_SECS (default 600s / 10 min).
916
+ maybe_refresh_ahead() {
917
+ local mode expires_at skew now exp
918
+ mode="$(jq -r '.auth.mode // empty' "$CFG" 2>/dev/null || true)"
919
+ [[ "$mode" == "user-token" ]] || return 0
920
+ skew="${MEETLESS_HOOK_REFRESH_SKEW_SECS:-600}"
921
+ expires_at="$(jq -r '.auth.accessExpiresAt // empty' "$CFG" 2>/dev/null || true)"
922
+ if [[ -n "$expires_at" ]]; then
923
+ exp="$(iso_to_epoch "$expires_at" 2>/dev/null || true)"
924
+ now="$(date +%s 2>/dev/null || echo 0)"
925
+ # Comfortably fresh => skip the spawn entirely (the hot-path-clean case).
926
+ if [[ -n "$exp" && "$now" -gt 0 && $((exp - now)) -gt "$skew" ]]; then
927
+ return 0
928
+ fi
929
+ fi
930
+ # Near expiry, unparseable, or unknown: let the TS gate decide (it re-checks the
931
+ # same skew and no-ops cheaply when the token is actually still fresh).
932
+ refresh_user_token "$skew" || true
933
+ return 0
934
+ }