@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.
- package/LICENSE +201 -0
- package/README.md +81 -0
- package/dist/build-info.json +9 -0
- package/dist/bundles/ask-core.js +396 -0
- package/dist/bundles/mcp.js +16592 -0
- package/dist/bundles/trace-core.js +263 -0
- package/dist/cli.js +828 -0
- package/dist/commands/activate.js +781 -0
- package/dist/commands/adoption.js +130 -0
- package/dist/commands/ask.js +290 -0
- package/dist/commands/context.js +114 -0
- package/dist/commands/debug.js +313 -0
- package/dist/commands/doctor.js +1021 -0
- package/dist/commands/enrich.js +427 -0
- package/dist/commands/evidence.js +229 -0
- package/dist/commands/flush.js +184 -0
- package/dist/commands/graph.js +104 -0
- package/dist/commands/init.js +272 -0
- package/dist/commands/internal-active-review.js +322 -0
- package/dist/commands/internal-auto-index.js +188 -0
- package/dist/commands/internal-capture-decisions.js +320 -0
- package/dist/commands/internal-evidence-correlate.js +239 -0
- package/dist/commands/internal-evidence-hooks.js +240 -0
- package/dist/commands/internal-evidence-inject.js +231 -0
- package/dist/commands/internal-finalize.js +221 -0
- package/dist/commands/internal-pretool-observe.js +225 -0
- package/dist/commands/internal-refresh.js +136 -0
- package/dist/commands/internal-session-nudge.js +120 -0
- package/dist/commands/internal-steer-sync.js +117 -0
- package/dist/commands/internal-turn-recap.js +140 -0
- package/dist/commands/kb.js +375 -0
- package/dist/commands/kb_add.js +681 -0
- package/dist/commands/kb_forget.js +283 -0
- package/dist/commands/kb_move.js +45 -0
- package/dist/commands/kb_pending.js +410 -0
- package/dist/commands/kb_personal.js +149 -0
- package/dist/commands/kb_promote.js +188 -0
- package/dist/commands/kb_purge.js +168 -0
- package/dist/commands/kb_reingest.js +335 -0
- package/dist/commands/kb_retime.js +170 -0
- package/dist/commands/kb_review.js +391 -0
- package/dist/commands/kb_revision.js +179 -0
- package/dist/commands/kb_show.js +385 -0
- package/dist/commands/label.js +226 -0
- package/dist/commands/login.js +295 -0
- package/dist/commands/logout.js +108 -0
- package/dist/commands/mcp-supervisor.js +93 -0
- package/dist/commands/mcp.js +227 -0
- package/dist/commands/queue-prune.js +98 -0
- package/dist/commands/review.js +358 -0
- package/dist/commands/rewire.js +124 -0
- package/dist/commands/rules.js +728 -0
- package/dist/commands/scan-context.js +67 -0
- package/dist/commands/session.js +347 -0
- package/dist/commands/stats.js +479 -0
- package/dist/commands/status.js +61 -0
- package/dist/commands/summary.js +250 -0
- package/dist/commands/turn.js +114 -0
- package/dist/commands/uninstall.js +222 -0
- package/dist/commands/whoami.js +102 -0
- package/dist/commands/workspace.js +130 -0
- package/dist/hooks-template/ce0-post-tool-use.sh +34 -0
- package/dist/hooks-template/ce0-session-start.sh +49 -0
- package/dist/hooks-template/ce0-stop.sh +29 -0
- package/dist/hooks-template/ce0-user-prompt-submit.sh +38 -0
- package/dist/hooks-template/common.sh +934 -0
- package/dist/hooks-template/event-batch-filter.jq +67 -0
- package/dist/hooks-template/flush.sh +503 -0
- package/dist/hooks-template/post-tool-use.sh +423 -0
- package/dist/hooks-template/pre-tool-use.sh +69 -0
- package/dist/hooks-template/session-start.sh +140 -0
- package/dist/hooks-template/stop.sh +308 -0
- package/dist/hooks-template/user-prompt-submit.sh +1162 -0
- package/dist/lib/activation.js +79 -0
- package/dist/lib/active-conflict-cache.js +141 -0
- package/dist/lib/active-memory.js +59 -0
- package/dist/lib/active-review-runner.js +26 -0
- package/dist/lib/agent-decision/index.js +25 -0
- package/dist/lib/agent-decision/keys.js +49 -0
- package/dist/lib/agent-decision/normalize-claude.js +183 -0
- package/dist/lib/agent-decision/types.js +21 -0
- package/dist/lib/agent-decision/validate.js +216 -0
- package/dist/lib/analytics/capture.js +96 -0
- package/dist/lib/analytics/command-event.js +267 -0
- package/dist/lib/analytics/consent.js +58 -0
- package/dist/lib/analytics/coverage-gap.js +96 -0
- package/dist/lib/analytics/envelope.js +236 -0
- package/dist/lib/analytics/event-id.js +86 -0
- package/dist/lib/analytics/evidence.js +150 -0
- package/dist/lib/analytics/followthrough.js +194 -0
- package/dist/lib/analytics/forwarder.js +109 -0
- package/dist/lib/analytics/logs.js +78 -0
- package/dist/lib/analytics/metrics.js +78 -0
- package/dist/lib/analytics/recorder.js +92 -0
- package/dist/lib/analytics/review-analytics.js +75 -0
- package/dist/lib/analytics/sequence.js +77 -0
- package/dist/lib/analytics/store.js +131 -0
- package/dist/lib/analytics/turn-recap.js +279 -0
- package/dist/lib/artifact_id.js +108 -0
- package/dist/lib/auth-breaker.js +161 -0
- package/dist/lib/auto-index.js +112 -0
- package/dist/lib/classifier.js +88 -0
- package/dist/lib/config.js +298 -0
- package/dist/lib/conflict-advisory.js +64 -0
- package/dist/lib/debug-bundle.js +520 -0
- package/dist/lib/enrichment/ingest.js +301 -0
- package/dist/lib/enrichment/plan.js +253 -0
- package/dist/lib/enrichment/protocol.js +359 -0
- package/dist/lib/enrichment/scout-brief.js +176 -0
- package/dist/lib/failure-telemetry.js +444 -0
- package/dist/lib/git.js +200 -0
- package/dist/lib/governance-cache.js +77 -0
- package/dist/lib/governed-path-cache.js +76 -0
- package/dist/lib/http.js +677 -0
- package/dist/lib/identity-envelope.js +23 -0
- package/dist/lib/kb-candidate.js +65 -0
- package/dist/lib/kb_acl.js +98 -0
- package/dist/lib/login.js +353 -0
- package/dist/lib/mcp-fetchers.js +130 -0
- package/dist/lib/mcp-restart.js +47 -0
- package/dist/lib/observability.js +805 -0
- package/dist/lib/open-url.js +33 -0
- package/dist/lib/orphan-guard.js +70 -0
- package/dist/lib/packaged.js +21 -0
- package/dist/lib/reconcile-sessions.js +171 -0
- package/dist/lib/redactor.js +89 -0
- package/dist/lib/relationship-candidate-query.js +27 -0
- package/dist/lib/render.js +611 -0
- package/dist/lib/rules/applicability.js +64 -0
- package/dist/lib/rules/attest-code-rule-version.js +47 -0
- package/dist/lib/rules/attest-notes-location.js +217 -0
- package/dist/lib/rules/attest-rule-version.js +69 -0
- package/dist/lib/rules/canonical-json.js +97 -0
- package/dist/lib/rules/ce0-emit.js +64 -0
- package/dist/lib/rules/ce0-evidence.js +281 -0
- package/dist/lib/rules/ce0-recall-sample.js +82 -0
- package/dist/lib/rules/ce0-rule.js +55 -0
- package/dist/lib/rules/ce0-sampling-bucket.js +15 -0
- package/dist/lib/rules/ce0-store.js +683 -0
- package/dist/lib/rules/ce0-telemetry-project.js +93 -0
- package/dist/lib/rules/ce0-telemetry.js +158 -0
- package/dist/lib/rules/code-rule-registry.js +17 -0
- package/dist/lib/rules/command-match.js +185 -0
- package/dist/lib/rules/consult-evidence-binding.js +27 -0
- package/dist/lib/rules/consultation-capture-adapter.js +193 -0
- package/dist/lib/rules/content-match.js +56 -0
- package/dist/lib/rules/deny-admission.js +99 -0
- package/dist/lib/rules/durable-observation.js +190 -0
- package/dist/lib/rules/enforce-notes-version.js +421 -0
- package/dist/lib/rules/evaluation-input-hash.js +126 -0
- package/dist/lib/rules/evaluator.js +108 -0
- package/dist/lib/rules/inert-rule-families.js +51 -0
- package/dist/lib/rules/input-authority-resolver.js +241 -0
- package/dist/lib/rules/interception-schema.js +170 -0
- package/dist/lib/rules/interception-store.js +267 -0
- package/dist/lib/rules/live-input-authority.js +66 -0
- package/dist/lib/rules/local-matcher.js +108 -0
- package/dist/lib/rules/local-observe.js +79 -0
- package/dist/lib/rules/local-rule-version-repo.js +214 -0
- package/dist/lib/rules/memory-requirement.js +109 -0
- package/dist/lib/rules/notes-observe.js +39 -0
- package/dist/lib/rules/notes-path.js +261 -0
- package/dist/lib/rules/notes-rule.js +75 -0
- package/dist/lib/rules/observe-adapter.js +114 -0
- package/dist/lib/rules/observed-rule-hash.js +119 -0
- package/dist/lib/rules/prompt-submit-adapter.js +132 -0
- package/dist/lib/rules/requirement-subject.js +240 -0
- package/dist/lib/rules/rule-activity.js +67 -0
- package/dist/lib/rules/rule-version-hash.js +151 -0
- package/dist/lib/rules/runtime-scope.js +55 -0
- package/dist/lib/rules/stop-adapter.js +116 -0
- package/dist/lib/rules/stop-response-snapshot.js +174 -0
- package/dist/lib/rules/types.js +10 -0
- package/dist/lib/rules/ulid.js +46 -0
- package/dist/lib/rules/version-evaluation.js +156 -0
- package/dist/lib/scanner/agent-memory.js +99 -0
- package/dist/lib/scanner/bootstrap-summary.js +87 -0
- package/dist/lib/scanner/cache.js +59 -0
- package/dist/lib/scanner/frontmatter.js +42 -0
- package/dist/lib/scanner/parse-directives.js +69 -0
- package/dist/lib/scanner/parse-structured.js +72 -0
- package/dist/lib/scanner/render.js +73 -0
- package/dist/lib/scanner/scan.js +132 -0
- package/dist/lib/scanner/score.js +38 -0
- package/dist/lib/scanner/scout-mission.js +126 -0
- package/dist/lib/scanner/types.js +7 -0
- package/dist/lib/session-scope.js +195 -0
- package/dist/lib/spool.js +355 -0
- package/dist/lib/staleness.js +100 -0
- package/dist/lib/steer-cache.js +87 -0
- package/dist/lib/tagged-reference.js +20 -0
- package/dist/lib/temporal.js +109 -0
- package/dist/lib/turn-recap-emit.js +67 -0
- package/dist/lib/unwire.js +253 -0
- package/dist/lib/update-check.js +469 -0
- package/dist/lib/update-notifier.js +217 -0
- package/dist/lib/upgrade-apply.js +643 -0
- package/dist/lib/wire.js +1087 -0
- package/dist/lib/workspace.js +96 -0
- package/dist/lib/zip.js +154 -0
- package/dist/pretool-entry.js +37 -0
- 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
|
+
}
|