@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,423 @@
1
+ #!/usr/bin/env bash
2
+ # post-tool-use.sh: Claude Code PostToolUse hook (Bash + meetless MCP).
3
+ #
4
+ # Two routes, selected by tool name:
5
+ # Bash -> tool_used_bash event (command, exit code,
6
+ # stdout/stderr tails, category HINT) spooled to
7
+ # the queue; Worker re-categorizes (Smaller-D).
8
+ # mcp__meetless__meetless__* -> tool_used_mcp record of the agent's OWN
9
+ # evidence pull, written LOCALLY to
10
+ # logs/mcp-calls.jsonl keyed by (session_id,
11
+ # turn_index). This is the "pull" side of A1
12
+ # evidence-followthrough: ask-traces.jsonl says
13
+ # what we injected on a turn, mcp-calls.jsonl
14
+ # says what the agent pulled on the same turn.
15
+ # relationship_verdict is an ACTION, never an
16
+ # evidence Pull (evidence_tool=false). See
17
+ # notes/20260603-mla-kb-agent-proxy-and-evidence-adoption.md
18
+ # §7.1 P1 / §7.4 A1.
19
+ # Any other tool is ignored.
20
+ #
21
+ # Source: notes/20260527-bare-bones-mvp-codebase-evaluation-and-plan.md §5.2.
22
+ source "$(dirname "$0")/common.sh"
23
+
24
+ # Per-folder activation gate (opt-in). Exit before any work unless a
25
+ # `.meetless.json` marker is found by walking up from $PWD. See
26
+ # meetless_activated in common.sh. Run `mla activate` in a repo to opt in.
27
+ meetless_activated || exit 0
28
+
29
+ INPUT="$(cat)"
30
+ # Wedge v6 Epoch 29: validate stdin parses as JSON BEFORE any jq substitution.
31
+ # See session-start.sh for the trap rationale.
32
+ if [[ -z "$INPUT" ]] || ! printf '%s' "$INPUT" | jq -e . >/dev/null 2>&1; then
33
+ exit 0
34
+ fi
35
+ SESSION_ID="$(echo "$INPUT" | jq -r '.session_id // empty')"
36
+ TOOL="$(echo "$INPUT" | jq -r '.tool_name // empty')"
37
+ [[ -z "$SESSION_ID" ]] && exit 0
38
+ # Per-session OFF override (`mla deactivate`). Silences this one session even in
39
+ # an activated folder. See meetless_session_disabled in common.sh.
40
+ meetless_session_disabled "$SESSION_ID" && exit 0
41
+
42
+ # F3-B liveness heartbeat. Throttled detached flush (<=1 per ~60s/session) drains
43
+ # the events already queued this turn so a long, tool-heavy turn keeps control's
44
+ # lastSeenAt fresh and stays LIVE instead of aging into IDLE mid-work. Spools no
45
+ # new event; fail-soft. Runs for EVERY tool (including non-spooling Read/Grep) so
46
+ # the heartbeat covers the whole turn, not just file/bash tools.
47
+ heartbeat_flush "$SESSION_ID"
48
+
49
+ # ---- Intra-turn narration capture (LIVE, per assistant entry) ------------
50
+ # The agent's visible prose between tool calls is the "line of thought" the
51
+ # session timeline needs INTERLEAVED with the commands. The Stop hook also
52
+ # captures narration, but only as ONE blob stamped at Stop-time -- so it lumps
53
+ # at the end of the turn (never interleaved) and reads the transcript only as it
54
+ # exists at Stop, AFTER a mid-turn auto-compaction has already destroyed the
55
+ # earlier prose. This hook fires LIVE after every tool, so it records each
56
+ # assistant text entry at its OWN transcript timestamp (correct interleave) and
57
+ # BEFORE a later compaction can drop it (compaction-robust). Dogfood-audit
58
+ # 2026-06-12: session f16d5e9a rendered as a wall of commands with no prose.
59
+ #
60
+ # Each entry is keyed by its transcript uuid (assistant_message:<uuid>) so a
61
+ # re-fired hook and the Stop backstop are idempotent against control's
62
+ # (runId, eventKey) dedup. A per-session ts cursor stops us re-spooling prose we
63
+ # already captured on the previous tool. The turn's CLOSING message
64
+ # (stop_reason end_turn) is EXCLUDED -- that is the Stop hook's finalMessage, not
65
+ # narration, and excluding it also stops a later turn from re-capturing the prior
66
+ # turn's closer. Only `text` content is taken (thinking blocks stay private).
67
+ # Runs for EVERY tool (incl. non-spooling Read/Grep) so prose on a read-only turn
68
+ # is not lost. Best-effort and fail-soft: a missing/unreadable transcript or any
69
+ # jq error skips capture and never disturbs the tool spool below. Narration is the
70
+ # default now (no kill switch): the timeline is wrong without it.
71
+ NARR_TRANSCRIPT="$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
72
+ if [[ -n "$NARR_TRANSCRIPT" && -f "$NARR_TRANSCRIPT" ]]; then
73
+ mkdir -p "$QUEUE_DIR"
74
+ NARR_CURSOR_FILE="$QUEUE_DIR/$SESSION_ID.narration-cursor"
75
+ (
76
+ flock 8
77
+ NARR_CURSOR="$(cat "$NARR_CURSOR_FILE" 2>/dev/null || echo '')"
78
+ # tail caps the per-fire read; the cursor guarantees completeness because
79
+ # each narration entry is followed immediately by the tool_use that fires
80
+ # this hook, so a new entry is always within the recent window.
81
+ NARR_LINES="$(tail -n 1200 "$NARR_TRANSCRIPT" 2>/dev/null | jq -c -R --slurp \
82
+ --arg sid "$SESSION_ID" --arg cursor "$NARR_CURSOR" '
83
+ split("\n")
84
+ | map(select(length > 0) | fromjson?)
85
+ | map(select(type == "object"))
86
+ | [ .[]
87
+ | select(.type == "assistant")
88
+ | select((.message.stop_reason // "") != "end_turn")
89
+ | { uuid: (.uuid // ""),
90
+ ts: (.timestamp // ""),
91
+ text: ( (.message.content // [])
92
+ | if type == "array"
93
+ then map(select((.type? // "") == "text") | (.text? // "")) | join("\n")
94
+ else "" end ) }
95
+ | select(.uuid != "" and .ts != "")
96
+ | select((.text | gsub("\\s"; "") | length) > 0)
97
+ | select(.ts > $cursor) ]
98
+ | .[]
99
+ | { ts: .ts,
100
+ event: "assistant_message",
101
+ eventKey: ("assistant_message:" + .uuid),
102
+ sessionId: $sid,
103
+ payload: { narration: .text, entryUuid: .uuid } }
104
+ ' 2>/dev/null || true)"
105
+ if [[ -n "$NARR_LINES" ]]; then
106
+ while IFS= read -r NARR_LINE; do
107
+ [[ -z "$NARR_LINE" ]] && continue
108
+ spool_append "$SESSION_ID" "$NARR_LINE"
109
+ done <<< "$NARR_LINES"
110
+ NARR_NEW_CURSOR="$(printf '%s\n' "$NARR_LINES" | jq -rs 'map(.ts) | max // empty' 2>/dev/null || true)"
111
+ [[ -n "$NARR_NEW_CURSOR" ]] && printf '%s' "$NARR_NEW_CURSOR" > "$NARR_CURSOR_FILE"
112
+ fi
113
+ ) 8>"$NARR_CURSOR_FILE.lock" || true
114
+ fi
115
+
116
+ # ---- meetless MCP-call capture (P1) -------------------------------------
117
+ # Record the agent's own evidence pulls before the Bash path. These land in a
118
+ # LOCAL file (not the Control spool) since A1 joins them against the local
119
+ # enrichment trace; keeping them out of the queue also leaves the Bash spool
120
+ # contract untouched.
121
+ if [[ "$TOOL" == mcp__meetless__meetless__* ]]; then
122
+ MCP_TOOL="${TOOL##*meetless__}"
123
+ # Evidence-bearing pulls vs actions. relationship_verdict mutates governance
124
+ # state; it is NOT a Pull for A1a. The three read tools return cited evidence.
125
+ case "$MCP_TOOL" in
126
+ retrieve_knowledge|kb_doc_detail|query) EVIDENCE_TOOL=true ;;
127
+ *) EVIDENCE_TOOL=false ;;
128
+ esac
129
+ QUERY="$(echo "$INPUT" | jq -r '.tool_input.query // .tool_input.question // .tool_input.citation // ""')"
130
+ # Scan both the call args and its result for citation tokens (the source_ids
131
+ # the agent actually touched). tojson keeps object/array/string shapes flat
132
+ # and preserves the literal [XX:id] tokens; extract_source_ids (common.sh) is
133
+ # the one shared grammar across the pull and push-reference sides.
134
+ SCAN="$(echo "$INPUT" | jq -r '{i: .tool_input, r: (.tool_response // .tool_result)} | tojson')"
135
+ SOURCE_IDS_JSON="$(extract_source_ids "$SCAN")"
136
+ # Attribute to the CURRENT turn (read, never advance: next_turn_index is owned
137
+ # by UserPromptSubmit). mkdir guards a tool call arriving before any prompt.
138
+ mkdir -p "$QUEUE_DIR" "$LOG_DIR"
139
+ TURN="$(current_turn_index "$SESSION_ID")"
140
+ TS="$(date -u +%FT%TZ)"
141
+ LINE="$(jq -c -n \
142
+ --arg ts "$TS" --arg event "tool_used_mcp" \
143
+ --arg sessionId "$SESSION_ID" --argjson turn "$TURN" \
144
+ --arg tool "$MCP_TOOL" --argjson evidence "$EVIDENCE_TOOL" \
145
+ --arg query "$QUERY" --argjson sids "$SOURCE_IDS_JSON" \
146
+ '{ts: $ts, event: $event, session_id: $sessionId, turn_index: $turn, tool: $tool, evidence_tool: $evidence, query: $query, source_ids: $sids}')"
147
+ (
148
+ flock 9
149
+ printf '%s\n' "$LINE" >> "$LOG_DIR/mcp-calls.jsonl"
150
+ ) 9>"$LOG_DIR/mcp-calls.lock"
151
+
152
+ # ---- InjectionTrace parity for the MCP surface (P0.2, design §7.6) --------
153
+ # MCP grounding is an INJECTION surface: an evidence-bearing pull returns cited
154
+ # relationships INTO this turn's context. The stateless MCP server has no session
155
+ # identity, but THIS hook does (SESSION_ID + the read-only TURN), so it is the
156
+ # one place that can emit an InjectionTrace-compatible record for the MCP path,
157
+ # reconciled to the run by riding its own session's event stream (the SAME spool
158
+ # + flush pipeline as the HOOK producer in user-prompt-submit.sh). Without it the
159
+ # session-detail "Injected" lane reads empty for an MCP-grounded run even though
160
+ # relationships were injected -- which is the exact dishonesty §7.6 makes P0.
161
+ #
162
+ # Lean §7.6 superset: contextItems are the citation tokens the grounding actually
163
+ # returned (no kind/status/confidence agentic enrichment), sourceSurface=MCP tells
164
+ # the console it is reading the lean shape. Emit ONLY on a REAL injection: an
165
+ # evidence tool that returned >=1 cited source (a pull with no citation injected
166
+ # nothing; relationship_verdict is an ACTION, EVIDENCE_TOOL=false, never an
167
+ # injection). deliveryStatus is stamped INJECTED HERE, by the surface, at the
168
+ # delivery moment (INV-INJECTIONTRACE-DELIVERY). The injectId IS the eventKey
169
+ # (minted once, replayed byte-identical on a re-flush so control's 6-tuple
170
+ # idempotency no-ops the retry); traceId reuses it since the MCP pull has no
171
+ # separate enrich trace id. Best-effort and fail-soft: a jq failure omits the
172
+ # record and never disturbs the local pull capture above.
173
+ if [[ "$EVIDENCE_TOOL" == "true" ]]; then
174
+ IT_ITEMS="$(printf '%s' "$SOURCE_IDS_JSON" | jq -c \
175
+ '[ (. // []) | unique | .[] | select(. != "") | {source_id: ., injected: true} ]' \
176
+ 2>/dev/null || printf '[]')"
177
+ IT_COUNT="$(printf '%s' "$IT_ITEMS" | jq 'length' 2>/dev/null || printf 0)"
178
+ if [[ "${IT_COUNT:-0}" -gt 0 ]]; then
179
+ IT_KEY="$(gen_event_key)"
180
+ IT_LINE="$(jq -c -n \
181
+ --arg ts "$TS" --arg key "$IT_KEY" --arg session_id "$SESSION_ID" \
182
+ --argjson turn "${TURN:-0}" --argjson items "$IT_ITEMS" \
183
+ '{
184
+ ts: $ts, event: "injection_trace", eventKey: $key, sessionId: $session_id,
185
+ payload: {
186
+ sourceSurface: "MCP", turnIndex: $turn, injectId: $key, traceId: $key,
187
+ deliveryStatus: "INJECTED", schemaVersion: 1, status: null, confidence: null,
188
+ contextItems: $items, markdown: null, capturedAt: $ts
189
+ }
190
+ }' 2>/dev/null || true)"
191
+ [[ -n "$IT_LINE" ]] && spool_append "$SESSION_ID" "$IT_LINE"
192
+ fi
193
+ fi
194
+ exit 0
195
+ fi
196
+
197
+ # ---- AskUserQuestion agent-decision capture (provider-neutral) -----------
198
+ # Claude's AskUserQuestion bundles N questions in one tool call; each ANSWERED
199
+ # question becomes one first-class, auditable agent-human decision. Hand the raw
200
+ # hook payload (tool_input.questions + tool_response.answers + tool_use_id) to the
201
+ # `mla _internal capture-decisions` Claude normalizer, which emits one
202
+ # `agent_decision_captured` spool event per answered question (providerEventId =
203
+ # "<tool_use_id>#<questionIndex>"). The command is an IO-light PURE transform and
204
+ # touches NO spool itself; ALL spool locking stays HERE so the single-writer
205
+ # invariant lives in one place. Passing --spool lets the command dedup against
206
+ # eventKeys already queued this session (the Stop transcript-scan backstop writes
207
+ # the same keys), so a re-fired PostToolUse never double-spools the same decision.
208
+ # Capture is assistive: every failure is swallowed and never breaks the session.
209
+ # See notes/20260608-agent-decision-capture-design.md section 5.
210
+ if [[ "$TOOL" == "AskUserQuestion" ]]; then
211
+ if [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]]; then
212
+ DECISION_LINES="$(printf '%s' "$INPUT" | "$MLA_PATH" _internal capture-decisions \
213
+ --source post_tool_use --session "$SESSION_ID" \
214
+ --spool "$QUEUE_DIR/$SESSION_ID.jsonl" 2>/dev/null || true)"
215
+ while IFS= read -r DECISION_LINE; do
216
+ [[ -z "$DECISION_LINE" ]] && continue
217
+ spool_append "$SESSION_ID" "$DECISION_LINE"
218
+ done <<< "$DECISION_LINES"
219
+ fi
220
+ exit 0
221
+ fi
222
+
223
+ # ---- Governed trace: tool_used_file on file-modifying tools ---------------
224
+ # Dogfood-audit 2026-06-10 issue 3: tool capture was bash-only, so a code-only
225
+ # session (all Write/Edit, no Bash) left ZERO governed tool trace in control.
226
+ # Spool ONE metadata-only event per file-modifying call: {tool, filePath}. No
227
+ # file content, no diff, no tool I/O (the v0 privacy boundary stays intact; a
228
+ # path is milder evidence than the stdout/stderr tails the Bash spool ships).
229
+ #
230
+ # This is the PRIMARY governed artifact for a file-modifying turn, so it spools
231
+ # FIRST -- ahead of the assistive A2 produced-doc and DUR blocks below. Both of
232
+ # those can early-exit under `set -euo pipefail` (DUR exits 0 in every path; A2
233
+ # walks marker trees and can abort), and anything placed after such a block
234
+ # never executes. Capturing the governed fact before any best-effort enrichment
235
+ # is what keeps the trace immune to a downstream abort (the prose-outside-marker
236
+ # regression that the dogfood-audit follow-up locked).
237
+ if [[ "$TOOL" == "Edit" || "$TOOL" == "Write" || "$TOOL" == "MultiEdit" || "$TOOL" == "NotebookEdit" ]]; then
238
+ FILE_TRACE_PATH="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty')"
239
+ if [[ -n "$FILE_TRACE_PATH" ]]; then
240
+ TS="$(date -u +%FT%TZ)"
241
+ EVENT_KEY="$(gen_event_key)"
242
+ LINE="$(jq -c -n \
243
+ --arg ts "$TS" --arg event "tool_used_file" --arg key "$EVENT_KEY" \
244
+ --arg sessionId "$SESSION_ID" --arg tool "$TOOL" --arg fp "$FILE_TRACE_PATH" \
245
+ '{ts: $ts, event: $event, eventKey: $key, sessionId: $sessionId, payload: {tool: $tool, filePath: $fp}}')"
246
+ spool_append "$SESSION_ID" "$LINE"
247
+ fi
248
+ fi
249
+
250
+ # ---- Read-side knowledge trace: tool_used_file with access:"read" ---------
251
+ # Session Files rail Phase 2 (notes/20260616-session-files-rail-design.md). The
252
+ # rail's "read by the agent" lane needs to know which PROSE files the agent
253
+ # opened; Read/Grep/Glob otherwise emit nothing. Spool ONE metadata-only
254
+ # tool_used_file per markdown Read, tagged access:"read" so the console routes it
255
+ # to the read lane (not produced) and the timeline labels it "Read a file" rather
256
+ # than "Edited a file". Gate STRICTLY to prose_path_allowed (the same allowlist
257
+ # the input/produced lanes use) so a code Read spools nothing and the stream is
258
+ # not flooded with every source-file open. Metadata only ({tool, filePath,
259
+ # access}), never file content. exit 0 keeps this read trace self-contained: the
260
+ # assistive A2 / DUR / Bash blocks below only handle modifying tools and Bash.
261
+ if [[ "$TOOL" == "Read" ]]; then
262
+ READ_TRACE_PATH="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')"
263
+ if [[ -n "$READ_TRACE_PATH" ]] && prose_path_allowed "$READ_TRACE_PATH"; then
264
+ TS="$(date -u +%FT%TZ)"
265
+ EVENT_KEY="$(gen_event_key)"
266
+ LINE="$(jq -c -n \
267
+ --arg ts "$TS" --arg event "tool_used_file" --arg key "$EVENT_KEY" \
268
+ --arg sessionId "$SESSION_ID" --arg tool "$TOOL" --arg fp "$READ_TRACE_PATH" \
269
+ '{ts: $ts, event: $event, eventKey: $key, sessionId: $sessionId, payload: {tool: $tool, filePath: $fp, access: "read"}}')"
270
+ spool_append "$SESSION_ID" "$LINE"
271
+ fi
272
+ exit 0
273
+ fi
274
+
275
+ # ---- Route 4: A2 produced/updated-doc capture (Zone 1, Phase 0) ----------
276
+ # Mark prose docs the agent wrote/edited this turn into the Active Review store.
277
+ # Pure local append: no detector, no KB write, no network. The envelope carries
278
+ # the real ownerUserId/workspaceId (never placeholders) so Phases 3-5 never
279
+ # migrate. Silence by default. Spec tests 1,2,7,24,40,41.
280
+ #
281
+ # ASSISTIVE + best-effort, so the whole block runs in a subshell guarded by
282
+ # `|| true`. NOTHING inside can abort the parent hook under `set -euo pipefail`:
283
+ # not meetless_repo_root returning 1 for a file outside every marker, not a
284
+ # content_hash shasum failure on an unreadable file (non-zero under pipefail),
285
+ # not a failed record_active_memory write, not any future addition. The primary
286
+ # tool_used_file trace already spooled ABOVE, so a hard abort here costs at most
287
+ # this turn's prose enrichment, never the governed trace and never a non-zero
288
+ # PostToolUse exit. Vars set here intentionally do not leak; no later block reads
289
+ # A2_*.
290
+ (
291
+ case "$TOOL" in
292
+ Write|Edit|MultiEdit|NotebookEdit)
293
+ A2_FILE="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty')"
294
+ if [[ -n "$A2_FILE" ]] && prose_path_allowed "$A2_FILE"; then
295
+ A2_ROOT="$(meetless_repo_root "$(dirname "$A2_FILE")")"
296
+ if [[ -n "$A2_ROOT" && -f "$A2_FILE" ]]; then
297
+ # T1.2 cutover: the produced doc belongs to the workspace of the folder
298
+ # it LIVES in (nearest-wins for the edited file), not the cli-config one
299
+ # and not necessarily the session's marker. A2_ROOT is that marker's dir.
300
+ A2_WS="$(jq -r '.workspaceId // empty' "$A2_ROOT/.meetless.json" 2>/dev/null)"
301
+ A2_OWNER="$(jq -r '.actorUserId // empty' "$CFG" 2>/dev/null)"
302
+ if [[ -n "$A2_WS" && -n "$A2_OWNER" ]]; then
303
+ A2_TURN="$(current_turn_index "$SESSION_ID")"
304
+ A2_RRH="$(repo_root_hash "$A2_ROOT")"
305
+ A2_CPATH="$(canonical_path "$A2_ROOT" "$A2_FILE")"
306
+ A2_CHASH="$(content_hash "$A2_FILE")"
307
+ record_active_memory "produced_doc" "$SESSION_ID" "$A2_TURN" "$A2_WS" "$A2_OWNER" "$A2_RRH" "$A2_CPATH" "$A2_CHASH" "$A2_ROOT"
308
+ fi
309
+ fi
310
+ fi
311
+ ;;
312
+ esac
313
+ ) || true
314
+
315
+ # ---- DUR: just-in-time coordination flag on a governed-surface edit (§5.4 DURING)
316
+ # When the agent edits/writes a file, raise an ADVISORY flag iff a high-confidence
317
+ # coordination trigger from THIS turn names the surface being touched ("this
318
+ # surface is governed by X") at the moment of the edit, not a judgment of the edit
319
+ # itself. Reuses the BEFORE-turn imperative's rung-2 contract: turn-keyed state +
320
+ # the closed CoordinationTrigger enum + the P5 high-confidence floor. It NEVER
321
+ # blocks (P6 "never its hands"): it emits hookSpecificOutput.additionalContext,
322
+ # never `decision: "block"`. Dormant by default in prod (detectors are the producer
323
+ # of coordination_triggers and are mostly unwired, so no state file is written and
324
+ # this no-ops). See notes/20260603-mla-kb-agent-proxy-and-evidence-adoption.md §5.4
325
+ # / §6 #9 / §7.2 row "DUR".
326
+ if [[ "$TOOL" == "Edit" || "$TOOL" == "Write" || "$TOOL" == "MultiEdit" || "$TOOL" == "NotebookEdit" ]]; then
327
+ # Kill switch (default on; set MEETLESS_COORDINATION_DURING=0 to silence).
328
+ [[ "${MEETLESS_COORDINATION_DURING:-1}" == "0" ]] && exit 0
329
+
330
+ FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty')"
331
+ [[ -z "$FILE_PATH" ]] && exit 0
332
+
333
+ STATE_FILE="$(coordination_state_file "$SESSION_ID")"
334
+ [[ -f "$STATE_FILE" ]] || exit 0
335
+
336
+ # Turn-match: stale state from a prior turn must NOT fire. current_turn_index
337
+ # peeks the turn UserPromptSubmit set; PostToolUse never advances it, so a
338
+ # mid-turn edit shares the enriched turn's index and an older file fails here.
339
+ STATE_TURN="$(jq -r '.turn_index // empty' "$STATE_FILE" 2>/dev/null || true)"
340
+ CUR_TURN="$(current_turn_index "$SESSION_ID" 2>/dev/null || printf 0)"
341
+ [[ -n "$STATE_TURN" && "$STATE_TURN" == "$CUR_TURN" ]] || exit 0
342
+
343
+ # P5 high-confidence floor (the same boundary the BEFORE-turn imperative holds;
344
+ # a trigger on a low/medium-confidence turn stays passive).
345
+ STATE_CONF="$(jq -r '.confidence // empty' "$STATE_FILE" 2>/dev/null || true)"
346
+ [[ "$STATE_CONF" == "high" ]] || exit 0
347
+
348
+ # Match the edited surface against this turn's triggers, hard-filtered to the
349
+ # closed enum (a malformed or injected type can never fire). The trigger surface
350
+ # is repo-relative; suffix-match it against the absolute edited path.
351
+ STATE_TRIGGERS="$(jq -c '.triggers // []' "$STATE_FILE" 2>/dev/null || printf '[]')"
352
+ MATCHED="$(printf '%s' "$STATE_TRIGGERS" | jq -c \
353
+ --arg fp "$FILE_PATH" --argjson enum "$COORDINATION_TRIGGER_ENUM" '
354
+ map(select(.type as $t | $enum | index($t)))
355
+ | map(select(.surface as $s
356
+ | ($s != null and $s != "")
357
+ and (($fp == $s) or ($fp | endswith("/" + $s)))))
358
+ ' 2>/dev/null || printf '[]')"
359
+ MATCH_COUNT="$(printf '%s' "$MATCHED" | jq 'length' 2>/dev/null || printf 0)"
360
+ [[ "${MATCH_COUNT:-0}" -gt 0 ]] || exit 0
361
+
362
+ # No spam: flag a given surface at most once per session.
363
+ mkdir -p "$(coordination_dir)" 2>/dev/null || true
364
+ FLAGGED_FILE="$(coordination_flagged_file "$SESSION_ID")"
365
+ if [[ -f "$FLAGGED_FILE" ]] && grep -qxF "$FILE_PATH" "$FLAGGED_FILE" 2>/dev/null; then
366
+ exit 0
367
+ fi
368
+ (
369
+ flock 9
370
+ printf '%s\n' "$FILE_PATH" >> "$FLAGGED_FILE"
371
+ ) 9>"$FLAGGED_FILE.lock"
372
+
373
+ STATE_TRACE="$(jq -r '.trace_id // ""' "$STATE_FILE" 2>/dev/null || true)"
374
+ COORD_LINES="$(printf '%s' "$MATCHED" | jq -r '.[] |
375
+ " - " + .type + (if (.ref // "") != "" then " -> " + .ref else "" end)' 2>/dev/null || true)"
376
+ CTX="<meetless-context kind=\"coordination\" surface=\"$FILE_PATH\" trace=\"$STATE_TRACE\">
377
+ You just edited a governed surface (just-in-time coordination flag): $FILE_PATH
378
+ Coordination applies before you rely on this change:
379
+ $COORD_LINES
380
+ This is a Meetless governance directive (computed server-side, not retrieved text). It is a reminder, not a block: Meetless never stops your tools. Pull the cited decision with meetless__kb_doc_detail and confirm the accountable owner signed off before you rely on this change.
381
+ </meetless-context>"
382
+ jq -n --arg ctx "$CTX" \
383
+ '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$ctx}}'
384
+ exit 0
385
+ fi
386
+
387
+ [[ "$TOOL" != "Bash" ]] && exit 0
388
+
389
+ CMD="$(echo "$INPUT" | jq -r '.tool_input.command // ""')"
390
+ EXIT_CODE="$(echo "$INPUT" | jq -r '.tool_result.exit_code // .tool_response.exit_code // empty')"
391
+ # Smaller-C: normalize empty/non-numeric to 0 BEFORE --argjson (jq dies on non-numeric).
392
+ if ! [[ "${EXIT_CODE:-}" =~ ^[0-9]+$ ]]; then EXIT_CODE=0; fi
393
+ # Truncate inside jq (codepoint-aware) so multibyte UTF-8 in tool output (e.g.
394
+ # Vietnamese console.log strings, emoji, accented test names) is never split
395
+ # mid-sequence. `tail -c 2000` cuts on bytes, which on a 3-byte vi character
396
+ # produces invalid UTF-8 that downstream jq --arg + Prisma JSON storage may
397
+ # silently corrupt or reject.
398
+ STDOUT_TAIL="$(echo "$INPUT" | jq -r '(.tool_result.stdout // .tool_response.stdout // "")[-2000:]')"
399
+ STDERR_TAIL="$(echo "$INPUT" | jq -r '(.tool_result.stderr // .tool_response.stderr // "")[-2000:]')"
400
+ TS="$(date -u +%FT%TZ)"
401
+ EVENT_KEY="$(gen_event_key)"
402
+
403
+ # Smaller-D: HINT only. The WORKER re-categorizes authoritatively from CMD.
404
+ CATEGORY_HINT="$(printf '%s' "$CMD" | awk '
405
+ /pytest|jest|vitest|mocha|go test|cargo test|pnpm test|npm test|yarn test/ {print "test"; exit}
406
+ /tsc|mypy|pyright/ {print "typecheck"; exit}
407
+ /eslint|ruff|flake8|prettier --check/ {print "lint"; exit}
408
+ /build|webpack|vite build|tsc -b|next build/ {print "build"; exit}
409
+ /prisma migrate|alembic|knex migrate/ {print "migration"; exit}
410
+ /npm i|pnpm i|pnpm add|yarn add|pip install|poetry add/ {print "package_install"; exit}
411
+ /^git / {print "git"; exit}
412
+ {print "unknown_bash"}
413
+ ')"
414
+
415
+ LINE="$(jq -c -n \
416
+ --arg ts "$TS" --arg event "tool_used_bash" --arg key "$EVENT_KEY" \
417
+ --arg sessionId "$SESSION_ID" --arg cmd "$CMD" --arg hint "$CATEGORY_HINT" \
418
+ --arg out "$STDOUT_TAIL" --arg err "$STDERR_TAIL" --argjson exit "$EXIT_CODE" \
419
+ '{ts: $ts, event: $event, eventKey: $key, sessionId: $sessionId, payload: {categoryHint: $hint, command: $cmd, exitCode: $exit, stdoutTail: $out, stderrTail: $err}}')"
420
+
421
+ spool_append "$SESSION_ID" "$LINE"
422
+
423
+ exit 0
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ # pre-tool-use.sh: Claude Code PreToolUse hook (R1 notes-location enforcement, A1).
3
+ #
4
+ # Scope (registered by wire.ts MANAGED_HOOK_SCRIPTS with matcher "^(Write|Edit)$"):
5
+ # fires only before a Write or Edit tool call. It hands the raw PreToolUse stdin to
6
+ # `mla _internal pretool-observe`, which runs the version-backed enforce seam and
7
+ # prints the hook response: either the empty `{}` pass-through, or a real deny body
8
+ # when a human-attested LIVE rule version is VIOLATED and the deny is admitted.
9
+ #
10
+ # This wrapper FORWARDS that response verbatim. The decision is computed by the
11
+ # subcommand (against a human-attested version), never by this script and never
12
+ # reflected from input. The subcommand always exits 0 and prints exactly one JSON
13
+ # body, so a non-empty stdout is a real, computed decision and is safe to relay.
14
+ #
15
+ # Fail open, always. No `set -e`: every step is best-effort. If `mla` is missing,
16
+ # crashes, hangs past the timeout, or prints nothing, this wrapper emits the empty
17
+ # `{}` pass-through and exits 0. A non-zero exit (especially 2) would BLOCK the tool,
18
+ # so this script NEVER exits non-zero: the decision rides the body, never the code.
19
+
20
+ INPUT="$(cat 2>/dev/null || true)"
21
+
22
+ # Resolve the absolute mla path the same way common.sh does (install-time path in
23
+ # cli-config.json, then PATH fallback). MLA in PATH is not relied upon.
24
+ CFG="${MEETLESS_HOME:-$HOME/.meetless}/cli-config.json"
25
+ MLA_PATH="$(jq -r '.mlaPath // empty' "$CFG" 2>/dev/null || true)"
26
+ if [[ -z "${MLA_PATH:-}" || ! -x "$MLA_PATH" ]]; then
27
+ MLA_PATH="$(command -v mla 2>/dev/null || true)"
28
+ fi
29
+
30
+ # Run a command under a wall-clock guard so a slow or stuck evaluation degrades to
31
+ # pass-through rather than hanging the tool. GNU `timeout` (or `gtimeout` from
32
+ # coreutils on macOS) is used when present; otherwise the command runs unguarded.
33
+ run_guarded() {
34
+ if command -v timeout >/dev/null 2>&1; then
35
+ timeout 5 "$@"
36
+ elif command -v gtimeout >/dev/null 2>&1; then
37
+ gtimeout 5 "$@"
38
+ else
39
+ "$@"
40
+ fi
41
+ }
42
+
43
+ # Prefer the minimal sibling entrypoint (`pretool-entry.js`, emitted next to the
44
+ # resolved mla binary) when present: it pays only the deny-decision require graph
45
+ # (~12ms cold) instead of cli.js's full command registry (~150ms), the latency
46
+ # lever from notes/20260615-...-consolidated-proposal.md. Both transports call the
47
+ # identical runInternalPretoolObserve core, so the decision body is byte-identical.
48
+ # When the sibling is absent (a pkg binary, an older install), fall back to
49
+ # `mla _internal pretool-observe` so the slow path stays correct. It is run the same
50
+ # way as mla (its `#!/usr/bin/env node` shebang resolves node), under the same guard.
51
+ RESPONSE=""
52
+ if [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]]; then
53
+ PRETOOL_ENTRY="$(dirname "$MLA_PATH")/pretool-entry.js"
54
+ if [[ -x "$PRETOOL_ENTRY" ]]; then
55
+ RESPONSE="$(printf '%s' "$INPUT" | run_guarded "$PRETOOL_ENTRY" 2>/dev/null || true)"
56
+ else
57
+ RESPONSE="$(printf '%s' "$INPUT" | run_guarded "$MLA_PATH" _internal pretool-observe 2>/dev/null || true)"
58
+ fi
59
+ fi
60
+
61
+ # Forward the computed decision body if there is one; otherwise fall open to the
62
+ # empty no-decision body. Stripping whitespace guards against a stray newline-only
63
+ # stdout being mistaken for a real response.
64
+ if [[ -n "${RESPONSE//[[:space:]]/}" ]]; then
65
+ printf '%s' "$RESPONSE"
66
+ else
67
+ printf '{}'
68
+ fi
69
+ exit 0
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bash
2
+ # session-start.sh: Claude Code SessionStart hook.
3
+ # Writes a session_started event to the spool and spawns a detached flush.
4
+ #
5
+ # Source: notes/20260527-bare-bones-mvp-codebase-evaluation-and-plan.md §5.2.
6
+ source "$(dirname "$0")/common.sh"
7
+
8
+ # Per-folder activation gate (opt-in). In an ACTIVATED repo we fall through to
9
+ # capture. In an UNACTIVATED repo we no longer exit silently: hand off to the CLI
10
+ # (which reuses the SAME marker resolver as `mla mcp`) to surface a one-line
11
+ # SessionStart explanation when warranted (logged-in git repos only); its stdout
12
+ # becomes Claude Code's additionalContext. No capture happens without a marker.
13
+ # See meetless_activated in common.sh. Run `mla activate` in a repo to opt in.
14
+ if ! meetless_activated; then
15
+ if [[ -n "${MLA_PATH:-}" && -x "$MLA_PATH" ]]; then
16
+ "$MLA_PATH" _internal session-nudge --cwd "$PWD" 2>/dev/null || true
17
+ fi
18
+ exit 0
19
+ fi
20
+
21
+ # Self-healing prune of dead hook entries in ~/.claude/settings.json.
22
+ # Background: hook entries can leak when temp worktrees (test fixtures, Claude
23
+ # Code worktrees, manual sandboxes) install themselves but skip cleanup on
24
+ # teardown. Without this, failed Stop / PostToolUse / etc. accumulate forever
25
+ # and Claude Code logs "Failed with non-blocking status code" on every event.
26
+ # Detection: walk every hook entry's `command` field; if it's a plain absolute
27
+ # path and does not exist on disk, drop that entry. Skips non-path commands
28
+ # (`pkill ...`, `~/.claude/...`, shell expressions). Whole block is best-effort
29
+ # and silenced so it can NEVER fail the hook.
30
+ {
31
+ __ml_settings="$HOME/.claude/settings.json"
32
+ if [[ -f "$__ml_settings" ]]; then
33
+ __ml_dead="$(jq -r '.hooks // {} | to_entries[] | .value[]? | .hooks[]?.command // empty' "$__ml_settings" 2>/dev/null \
34
+ | while IFS= read -r __cmd; do
35
+ [[ "$__cmd" =~ ^/[^[:space:]]+$ ]] && [[ ! -e "$__cmd" ]] && printf '%s\n' "$__cmd"
36
+ done)"
37
+ if [[ -n "$__ml_dead" ]]; then
38
+ __ml_dead_json="$(printf '%s\n' "$__ml_dead" | jq -R . | jq -s .)"
39
+ __ml_tmp="$__ml_settings.tmp.$$"
40
+ cp "$__ml_settings" "$__ml_settings.bak.meetless-prune-$(date +%Y%m%d-%H%M%S)" 2>/dev/null
41
+ if jq --argjson dead "$__ml_dead_json" '.hooks |= with_entries(.value |= map(select(([.hooks[]?.command] | map(tostring) | any(. as $c | $dead | index($c))) | not)))' "$__ml_settings" > "$__ml_tmp" 2>/dev/null \
42
+ && jq empty "$__ml_tmp" 2>/dev/null; then
43
+ mv "$__ml_tmp" "$__ml_settings"
44
+ else
45
+ rm -f "$__ml_tmp" 2>/dev/null
46
+ fi
47
+ fi
48
+ fi
49
+ } 2>/dev/null || true
50
+
51
+ INPUT="$(cat)"
52
+ # Wedge v6 Epoch 29: validate stdin parses as JSON BEFORE any jq substitution.
53
+ # Pre-fix the bare `SESSION_ID="$(echo "$INPUT" | jq -r ...)"` crashed under
54
+ # `set -euo pipefail` on empty stdin or malformed JSON: jq exits non-zero, the
55
+ # substitution propagates, the hook aborts BEFORE the empty-session-id guard
56
+ # below. Claude Code interprets that non-zero exit as a hook failure.
57
+ if [[ -z "$INPUT" ]] || ! printf '%s' "$INPUT" | jq -e . >/dev/null 2>&1; then
58
+ exit 0
59
+ fi
60
+ SESSION_ID="$(echo "$INPUT" | jq -r '.session_id // empty')"
61
+ [[ -z "$SESSION_ID" ]] && exit 0
62
+ # Per-session OFF override (`mla deactivate`). Silences this one session even in
63
+ # an activated folder. See meetless_session_disabled in common.sh.
64
+ meetless_session_disabled "$SESSION_ID" && exit 0
65
+
66
+ TRANSCRIPT="$(echo "$INPUT" | jq -r '.transcript_path // empty')"
67
+ CWD="$PWD"
68
+ BRANCH="$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
69
+ TS="$(date -u +%FT%TZ)"
70
+ EVENT_KEY="$(gen_event_key)"
71
+
72
+ # Best-effort current session name. A RESUMED session (`--resume` / `--continue`)
73
+ # starts with a transcript that already carries a title, so SessionStart is the
74
+ # earliest moment control can learn it (F3-A). Mirrors the local picker: human
75
+ # /title (`custom-title`) wins, else the auto-titler's name (`ai-title`). A brand
76
+ # -new session has neither, leaving the title empty; control's no-clobber guard
77
+ # keeps any prior name. See resolve_session_title in common.sh.
78
+ SESSION_TITLE="$(resolve_session_title "$TRANSCRIPT")"
79
+
80
+ LINE="$(jq -c -n \
81
+ --arg ts "$TS" --arg event "session_started" --arg key "$EVENT_KEY" \
82
+ --arg sessionId "$SESSION_ID" --arg transcript "$TRANSCRIPT" \
83
+ --arg cwd "$CWD" --arg branch "$BRANCH" --arg title "$SESSION_TITLE" \
84
+ '{ts: $ts, event: $event, eventKey: $key, sessionId: $sessionId, payload: {transcriptPath: $transcript, repoPath: $cwd, branch: $branch, sessionTitle: $title}}')"
85
+
86
+ # Wedge v6 Epoch 35: repoPath sidecar. flush.sh is nohup-spawned by hooks,
87
+ # so its cwd is whatever nohup ran in (often $HOME) -- NOT the repo. Without
88
+ # this sidecar, `mla _internal finalize-session` falls back to process.cwd(),
89
+ # captureGitEvidence returns empty topLevel, the Epoch 33 guard refuses to
90
+ # POST, finalize_requested is re-spooled, and the next flush re-fails the
91
+ # same way. Permanent stuck-loss until the user manually runs `mla flush`
92
+ # from inside the repo. The sidecar captures the SessionStart $CWD (Claude
93
+ # Code fires the hook with cwd = the project root) so flush.sh can export
94
+ # MEETLESS_REPO_PATH for the CLI to consume. Written BEFORE spool_append so
95
+ # the detached flush sees it on first try.
96
+ printf '%s' "$CWD" > "$QUEUE_DIR/$SESSION_ID.repoPath"
97
+
98
+ # T1.2 hard cutover (folder = workspace): workspaceId sidecar. The marker is the
99
+ # ONLY source of the workspaceId, and the nohup-detached flusher cannot walk up
100
+ # to it (cwd=$HOME would always miss the repo marker). meetless_activated above
101
+ # resolved WORKSPACE_ID from this session's marker; snapshot it here so flush.sh
102
+ # wraps every POST under the marker id, never a stale cli-config value. Written
103
+ # BEFORE spool_append so the detached flush sees it on first try; flush.sh removes
104
+ # it after a successful finalize, alongside .repoPath and .gitBaseline.
105
+ printf '%s' "$WORKSPACE_ID" > "$QUEUE_DIR/$SESSION_ID.workspaceId"
106
+
107
+ # Wedge v6: git baseline sidecar. Records the working tree's dirty state at
108
+ # SESSION START so finalize can subtract ambient changes (files already
109
+ # modified/deleted/untracked before the agent ran) and attribute only what the
110
+ # SESSION touched. Without this, `mla review` blamed pre-existing dirty state
111
+ # (e.g. a stray `.claude/scheduled_tasks.lock` deletion) on the run. Same
112
+ # `-c core.quotePath=false` + `--porcelain=v1` form as captureGitEvidence so the
113
+ # exact-line subtraction matches. Best-effort: a non-repo $CWD writes an empty
114
+ # file, which subtracts nothing (back-compatible).
115
+ #
116
+ # 2026-06-01 dogfood finding F-GIT-1 (RCA 20260531 §9.F): capture the baseline
117
+ # ONCE per session. Claude Code re-fires SessionStart with the SAME session_id on
118
+ # a CONTINUE / COMPACTION / RESUME. The old unconditional write re-captured the
119
+ # baseline AFTER the prior turns' edits, freezing the agent's own work in as
120
+ # "ambient" -- subtractBaseline then dropped it and `mla review` showed
121
+ # "changed files: 0" on a session with real uncommitted edits. The guard below
122
+ # preserves the true-start snapshot across resumes. flush.sh removes the sidecar
123
+ # after a successful finalize, so the next genuine segment re-captures fresh
124
+ # (the absent file IS the "this is a new start" signal).
125
+ if [[ ! -e "$QUEUE_DIR/$SESSION_ID.gitBaseline" ]]; then
126
+ git -C "$CWD" -c core.quotePath=false status --porcelain=v1 \
127
+ > "$QUEUE_DIR/$SESSION_ID.gitBaseline" 2>/dev/null || \
128
+ : > "$QUEUE_DIR/$SESSION_ID.gitBaseline"
129
+ fi
130
+
131
+ spool_append "$SESSION_ID" "$LINE"
132
+ spawn_flush "$SESSION_ID"
133
+
134
+ # Sweep for Claude Code sessions whose transcript was deleted on disk and archive
135
+ # the mirrored AgentRun. Claude Code has no "session deleted" event, so SessionStart
136
+ # is the throttling tick for this disk-reconciliation sweep. Detached + kill-switched
137
+ # (MEETLESS_SESSION_RECONCILE=0), so it can never delay or fail the hook.
138
+ spawn_reconcile
139
+
140
+ exit 0