@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,1021 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.REQUIRED_HOOK_EVENTS = exports.OPTIONAL_HOOKS = exports.REQUIRED_HOOKS = void 0;
37
+ exports.assertNoArgs = assertNoArgs;
38
+ exports.doctorExitCode = doctorExitCode;
39
+ exports.sessionCaptureCheck = sessionCaptureCheck;
40
+ exports.schemaVersionCheck = schemaVersionCheck;
41
+ exports.walModeCheck = walModeCheck;
42
+ exports.foreignKeysCheck = foreignKeysCheck;
43
+ exports.busyTimeoutCheck = busyTimeoutCheck;
44
+ exports.managedPreToolUseHookCheck = managedPreToolUseHookCheck;
45
+ exports.attestedPathRootCheck = attestedPathRootCheck;
46
+ exports.denyEmissionAccountingCheck = denyEmissionAccountingCheck;
47
+ exports.failOpenEnforcementCheck = failOpenEnforcementCheck;
48
+ exports.ce0IntegrityCheck = ce0IntegrityCheck;
49
+ exports.ce0QuickCheckResult = ce0QuickCheckResult;
50
+ exports.describeAuthMode = describeAuthMode;
51
+ exports.runDoctor = runDoctor;
52
+ const child_process_1 = require("child_process");
53
+ const fs = __importStar(require("fs"));
54
+ const os = __importStar(require("os"));
55
+ const path = __importStar(require("path"));
56
+ const config_1 = require("../lib/config");
57
+ const workspace_1 = require("../lib/workspace");
58
+ const http_1 = require("../lib/http");
59
+ const spool_1 = require("../lib/spool");
60
+ const activation_1 = require("../lib/activation");
61
+ const wire_1 = require("../lib/wire");
62
+ const ce0_store_1 = require("../lib/rules/ce0-store");
63
+ const interception_schema_1 = require("../lib/rules/interception-schema");
64
+ const live_input_authority_1 = require("../lib/rules/live-input-authority");
65
+ const deny_admission_1 = require("../lib/rules/deny-admission");
66
+ const interception_store_1 = require("../lib/rules/interception-store");
67
+ const local_rule_version_repo_1 = require("../lib/rules/local-rule-version-repo");
68
+ const attest_notes_location_1 = require("../lib/rules/attest-notes-location");
69
+ const runtime_scope_1 = require("../lib/rules/runtime-scope");
70
+ const evidence_1 = require("./evidence");
71
+ // `mla doctor` (§4.9, §6.4 step 14, Acceptance §11.14)
72
+ //
73
+ // Verifies the chain end to end. Red anywhere = block dogfood.
74
+ //
75
+ // 1. control reachable GET /internal/v1/health
76
+ // 2. token valid + workspace + actor + caseKind GET /internal/v1/whoami
77
+ // 3. intel reachable GET /health (if intelUrl configured)
78
+ // 4. ~/.claude/settings.json registers all required hooks
79
+ // 5. /mla skill installed
80
+ // 5b. Meetless MCP server registered (user scope ~/.claude.json, or project .mcp.json)
81
+ // 6. mlaPath resolves and is executable
82
+ // 7. ALL hook scripts present + executable under ~/.meetless/hooks/
83
+ // 8. queue depth (sessions, events, orphans, oldest event age)
84
+ //
85
+ // It also reports the TWO distinct lifecycles (folder = workspace, T3.3),
86
+ // kept separate so an operator can tell "this folder is bound" apart from
87
+ // "this session is being captured":
88
+ //
89
+ // 9. Workspace binding the `.meetless.json` marker (activate / deactivate):
90
+ // activated / not activated, workspaceId, marker path,
91
+ // and (via the whoami probe above) workspace exists /
92
+ // inaccessible.
93
+ // 10. Session capture the `<sid>.off` sentinel (mute / unmute):
94
+ // active / muted for THIS session. A folder can be
95
+ // activated while this session is muted.
96
+ exports.REQUIRED_HOOKS = [
97
+ "common.sh",
98
+ "session-start.sh",
99
+ "user-prompt-submit.sh",
100
+ "stop.sh",
101
+ "flush.sh",
102
+ // CE0 evidence-consultation measurement harness (proposal §4.1, §6.4). The four
103
+ // ce0-*.sh scripts are installed unconditionally by `mla rewire` (no opt-out flag,
104
+ // unlike post-tool-use.sh). They are REQUIRED, not OPTIONAL, for the same reason
105
+ // event-batch-filter.jq is: a binary upgrade without a re-rewire would leave them
106
+ // absent and silently break measurement. A missing CE0 script is otherwise
107
+ // invisible (not drift -- the byte check ignores absent files), so doctor must go
108
+ // RED here to force the re-rewire.
109
+ //
110
+ // The first three ride UserPromptSubmit / PostToolUse / Stop as second managed
111
+ // entries; absent, they silently under-record every turn. The fourth,
112
+ // ce0-session-start.sh, rides SessionStart and gives the offline §6.4 sweep an
113
+ // automatic caller: absent, the two precision/recall denominator events
114
+ // (memory_requirement_assessed, evidence_obligation_finalized) stop projecting and
115
+ // the ratios silently lose their denominator -- the same invisible-when-missing
116
+ // failure, so it is REQUIRED on the same footing.
117
+ "ce0-user-prompt-submit.sh",
118
+ "ce0-post-tool-use.sh",
119
+ "ce0-stop.sh",
120
+ "ce0-session-start.sh",
121
+ // Wedge v6 Epoch 27: the Pass 2 batch filter is a separate file so it can be
122
+ // unit-tested independently and shared between runtime + tests. If a user
123
+ // upgrades the mla binary but does NOT re-run `mla init`, their old flush.sh
124
+ // stays on disk and this file is missing -- but the NEW flush.sh (when it
125
+ // ships next) references it, so any future re-install + missed re-init would
126
+ // silently drop every event batch via the `|| echo "[]"` fallback. Doctor
127
+ // RED if the file is missing OR the installed flush.sh predates the
128
+ // filter-file extraction (content drift check below).
129
+ "event-batch-filter.jq",
130
+ // The live PreToolUse enforcement hook (A1). It pipes the raw PreToolUse stdin to
131
+ // `mla _internal pretool-observe`, which runs the version-backed enforce seam and emits the deny
132
+ // on the wire. Like the CE0 scripts it is installed unconditionally by `mla rewire` (only
133
+ // PostToolUse carries the --no-post-tool-use opt-out, wire.ts), so a binary upgrade that skipped a
134
+ // re-rewire would leave it absent and SILENTLY STOP ENFORCING under an otherwise GREEN doctor. RED
135
+ // on missing to force the re-rewire.
136
+ "pre-tool-use.sh",
137
+ ];
138
+ exports.OPTIONAL_HOOKS = ["post-tool-use.sh"];
139
+ // PreToolUse joins the required events with A1: wire.ts registers it unconditionally (it carries no
140
+ // opt-out flag, unlike PostToolUse), so a missing registration means the live enforcement hook never
141
+ // fires and the pilot silently stops enforcing. RED on missing.
142
+ exports.REQUIRED_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "Stop", "PreToolUse"];
143
+ const OPTIONAL_HOOK_EVENTS = ["PostToolUse"];
144
+ // `mla doctor` takes no arguments (Wedge v6 Epoch 49).
145
+ //
146
+ // Earlier versions documented and parsed a `--gc` flag but the parsed
147
+ // value was thrown away at the runDoctor entry point. The flag was a
148
+ // scaffold that was never wired to any GC behavior. Operators who
149
+ // reasonably typed `mla doctor --gc` expecting orphan cleanup got
150
+ // nothing AND no diagnostic that the flag did nothing -- silent
151
+ // documentation drift. Orphan `.jsonl.draining.*` recovery already
152
+ // happens automatically inside flush.sh on every flush, so there is
153
+ // no concrete GC operation that the doctor needs to perform.
154
+ //
155
+ // Removing the dead flag entirely is the honest fix. If a future
156
+ // epoch designs a real GC operation, it can be added as a separate
157
+ // subcommand (`mla doctor gc <args>`) with explicit semantics rather
158
+ // than re-introducing the old half-finished shape.
159
+ function assertNoArgs(argv) {
160
+ if (argv.length === 0)
161
+ return;
162
+ throw new Error(`\`mla doctor\` takes no arguments (got: ${argv.join(" ")}). The previous --gc flag was a no-op and has been removed.`);
163
+ }
164
+ function fmt(c) {
165
+ if (c.level === "info")
166
+ return ` ⓘ ${c.label}${c.detail ? `: ${c.detail}` : ""}`;
167
+ const mark = c.ok ? "✓" : "✗";
168
+ return ` ${mark} ${c.label}${c.detail ? ` (${c.detail})` : ""}`;
169
+ }
170
+ // The CI-gate exit contract: 1 when any non-info posture check is RED, else 0. The `level: "info"`
171
+ // carve-out is load-bearing. The append-only accounting rows (historical fail-open count, deny-emission
172
+ // backlog) report as info and must never fail the gate, because the ledger is append-only and one
173
+ // transient install-time fail-open would otherwise pin every future `mla doctor` non-zero forever. A
174
+ // genuine store fault (corrupt ce0, schema drift, busy_timeout drift, an inadmissible attested root) is
175
+ // not info, so it drives a non-zero exit and a CI / script `$?` check catches the degraded store.
176
+ // Pinned in doctor-exit-code.spec.ts.
177
+ function doctorExitCode(checks) {
178
+ return checks.some((c) => c.level !== "info" && !c.ok) ? 1 : 0;
179
+ }
180
+ // Session-capture lifecycle status (folder = workspace, T3.3). DISTINCT from the
181
+ // workspace-binding lifecycle (the `.meetless.json` marker): a folder can be
182
+ // activated while THIS session is muted. `mla mute` writes a `<sid>.off` sentinel
183
+ // into the session gate; `mla unmute` removes it. This reports whether the
184
+ // current live Claude Code session is muted, reading the gate dir directly so the
185
+ // check is a pure function of (sessionId, gateDir) and unit-pinned in
186
+ // doctor-session-capture.spec.ts.
187
+ //
188
+ // Always informational: a muted session is a deliberate, valid state, never a
189
+ // doctor failure (mirrors a dormant folder being info, not red). When there is no
190
+ // live session id, capture status is per-session and cannot be reported.
191
+ function sessionCaptureCheck(sessionId, gateDir) {
192
+ const sid = (sessionId || "").trim();
193
+ if (!sid) {
194
+ return {
195
+ ok: true,
196
+ level: "info",
197
+ label: "session capture: no live Claude Code session here (status is " +
198
+ "per-session; run `mla doctor` inside a session to see active / muted)",
199
+ };
200
+ }
201
+ const sid8 = sid.slice(0, 8);
202
+ const muted = fs.existsSync(path.join(gateDir, `${sid}.off`));
203
+ return {
204
+ ok: true,
205
+ level: "info",
206
+ label: muted
207
+ ? `session capture: MUTED for this session (${sid8}); run \`mla unmute\` to resume capture`
208
+ : `session capture: active for this session (${sid8})`,
209
+ };
210
+ }
211
+ // The four CE0 interception-store checks that gate the R1 notes-location deny pilot
212
+ // (proposal §10.1 step 1(d)). Each is a pure function of an already-read value so it is
213
+ // unit-pinned in doctor-ce0-schema.spec.ts, exactly like sessionCaptureCheck; the IO that
214
+ // reads the pragmas and the settings layers lives in runDoctor below. A red row here means a
215
+ // would-be deny would degrade to NONE at runtime, so doctor surfaces it before the pilot runs.
216
+ // (1) The local interception schema is at the version this binary expects. A mismatch means a
217
+ // stale or foreign database is open under the same path; the deny machinery must not run on it.
218
+ function schemaVersionCheck(actual, expected) {
219
+ const ok = actual === expected;
220
+ return {
221
+ ok,
222
+ label: "CE0 interception schema version",
223
+ detail: ok
224
+ ? `user_version ${actual}`
225
+ : `user_version ${actual}, expected ${expected}; rebuild the CE0 store`,
226
+ };
227
+ }
228
+ // (2) WAL journal mode keeps a PreToolUse read from ever blocking on a concurrent writer.
229
+ function walModeCheck(journalMode) {
230
+ const mode = (journalMode || "").toLowerCase();
231
+ const ok = mode === "wal";
232
+ return {
233
+ ok,
234
+ label: "CE0 store journal_mode = WAL",
235
+ detail: ok
236
+ ? undefined
237
+ : `journal_mode is ${journalMode || "unset"}; PreToolUse reads must not block on a writer`,
238
+ };
239
+ }
240
+ // (3) Foreign keys enforced so an evaluation row can never orphan its attempt or rule version.
241
+ function foreignKeysCheck(foreignKeys) {
242
+ const ok = foreignKeys === 1;
243
+ return {
244
+ ok,
245
+ label: "CE0 store foreign_keys = ON",
246
+ detail: ok
247
+ ? undefined
248
+ : `foreign_keys is ${foreignKeys}; evaluation rows could orphan their attempt or version`,
249
+ };
250
+ }
251
+ // (3b) busy_timeout stays small so a contended read fails fast (P0.15). The PreToolUse subcommand
252
+ // opens this store and reads it synchronously; if another process holds a write lock the read waits up
253
+ // to busy_timeout before SQLITE_BUSY. With busy_timeout <= 50 ms a contended read degrades fast (the
254
+ // seam's try/catch then fails open well inside the hook's wall-clock guard); a large value could
255
+ // instead stall the hook until that guard fires. openCe0Store hardcodes 50; this guards that number
256
+ // against drift, RED if it ever exceeds the ceiling. (The proposal's 500 ms hard-timeout figure is NOT
257
+ // the implemented guard: the managed pre-tool-use.sh wrapper uses a 5 s `timeout`; see the dogfood
258
+ // report's P0.15 latency section. We deliberately do not quote 500 ms here as if it were wired.)
259
+ function busyTimeoutCheck(busyTimeoutMs) {
260
+ const ok = busyTimeoutMs >= 0 && busyTimeoutMs <= 50;
261
+ return {
262
+ ok,
263
+ label: "CE0 store busy_timeout <= 50ms",
264
+ detail: ok
265
+ ? `busy_timeout ${busyTimeoutMs}ms`
266
+ : `busy_timeout is ${busyTimeoutMs}ms; a lock-contended read could stall the hook past its wall-clock guard before it fails open`,
267
+ };
268
+ }
269
+ // (4) MLA is the sole effective PreToolUse Write/Edit input authority (P0.58). Anything else
270
+ // (a foreign mutator, an unreadable layer, or no MLA hook at all) means a deny is not admissible.
271
+ function managedPreToolUseHookCheck(resolution) {
272
+ if (resolution.kind === "MLA_SOLE_AUTHORITY") {
273
+ return {
274
+ ok: true,
275
+ label: "MLA is the sole effective PreToolUse Write/Edit authority",
276
+ detail: `input authority config ${resolution.configHash.slice(0, 12)}`,
277
+ };
278
+ }
279
+ return {
280
+ ok: false,
281
+ label: "MLA is the sole effective PreToolUse Write/Edit authority",
282
+ detail: `${resolution.reason}: ${resolution.detail}`,
283
+ };
284
+ }
285
+ // (4b) The attested forbidden root resolves against the active runtime root (P0.63). A deny is
286
+ // admitted only when the path root resolves; if a LIVE rule is attested but its root will not resolve
287
+ // (no attested content, or the active runtime root is unresolved), a would-be deny silently fails open,
288
+ // so doctor goes RED. doctor passes the SAME resolveAttestedPathRoot result the enforce seam computes.
289
+ function attestedPathRootCheck(admission) {
290
+ if (admission.admitted) {
291
+ return {
292
+ ok: true,
293
+ label: "attested forbidden path root resolves (deny admissible)",
294
+ detail: admission.forbiddenRoot,
295
+ };
296
+ }
297
+ return {
298
+ ok: false,
299
+ label: "attested forbidden path root resolves (deny admissible)",
300
+ detail: `${admission.reason}: a would-be deny would silently fail open`,
301
+ };
302
+ }
303
+ // (4c) Honest deny-emission accounting (P0.60). A committed deny is recorded BEFORE it is emitted, so a
304
+ // crash in that window leaves a DECISION_RECORDED row that was never advanced to RESPONSE_EMITTED. That
305
+ // is honest and recoverable, NEVER corruption, so this is informational and never RED; it only surfaces
306
+ // the count so an operator can notice the hook recording denials it never emitted.
307
+ function denyEmissionAccountingCheck(awaitingEmission) {
308
+ return {
309
+ ok: true,
310
+ level: "info",
311
+ label: awaitingEmission === 0
312
+ ? "deny-emission accounting clean (no decisions awaiting emission)"
313
+ : `${awaitingEmission} deny decision(s) recorded but not yet emitted (honest crash-window leftovers; recoverable, never lost)`,
314
+ };
315
+ }
316
+ // (4d) Historical fail-open visibility. deny-admission.ts promises that when a DENY-ceiling violation
317
+ // cannot be denied (RULE_ENFORCEMENT_UNAVAILABLE, decision 5) the action passes, an alert fires, and the
318
+ // operator can see it here. Unlike a stuck deny-emission, a fail-open is NOT recoverable: the prohibited
319
+ // action already passed un-governed. But the rule_evaluation_record ledger is append-only and an
320
+ // install-time transient fail-open must not pin `mla doctor` RED forever, so the count is surfaced as
321
+ // info (the count IS the loud alert), never a permanent RED.
322
+ function failOpenEnforcementCheck(failedOpen) {
323
+ return {
324
+ ok: true,
325
+ level: "info",
326
+ label: failedOpen === 0
327
+ ? "enforcement has never failed open (no deny-ceiling violation passed un-governed)"
328
+ : `${failedOpen} deny-ceiling violation(s) failed open (passed un-governed); enforcement was unavailable at decision time`,
329
+ };
330
+ }
331
+ // (5) The local SQLite authority is structurally sound (P0.15). A PreToolUse hook fails OPEN on an
332
+ // invalid or unreadable local store, which silently takes enforcement DOWN, so P0.15 requires the
333
+ // degraded store be "surfaced through mla doctor as a failure". The other ce0 reads only catch
334
+ // corruption incidentally, when a query happens to touch a damaged page; this runs a deliberate
335
+ // full-database PRAGMA quick_check so a corrupt authority is reported authoritatively. Unlike the
336
+ // append-only accounting checks this is a LIVE infrastructure failure (enforcement is down right
337
+ // now), so it is RED, never info.
338
+ function ce0IntegrityCheck(quickCheckResult) {
339
+ const ok = quickCheckResult.trim().toLowerCase() === "ok";
340
+ if (ok) {
341
+ return { ok: true, label: "CE0 store integrity (PRAGMA quick_check)", detail: "ok" };
342
+ }
343
+ const summary = quickCheckResult.trim().slice(0, 200);
344
+ return {
345
+ ok: false,
346
+ label: "CE0 store integrity (PRAGMA quick_check)",
347
+ detail: `${summary}; the local SQLite authority is unreadable, enforcement is silently failing open`,
348
+ };
349
+ }
350
+ // Runs the deliberate full-database integrity scan behind ce0IntegrityCheck and returns "ok" when the
351
+ // store is sound, otherwise the failure text. Severe corruption makes better-sqlite3 THROW rather than
352
+ // return a row, so a throw is folded into the same failure string: either way the authority is unsound
353
+ // and the pure check above goes RED.
354
+ function ce0QuickCheckResult(store) {
355
+ try {
356
+ const rows = store.db.pragma("quick_check");
357
+ return rows.map((r) => String(Object.values(r)[0])).join("; ");
358
+ }
359
+ catch (e) {
360
+ return e.message;
361
+ }
362
+ }
363
+ // Newest mtime (ms) of any .ts file under `dir`, walked recursively. Used by
364
+ // the build-freshness check to detect a dist/ that lags behind src/.
365
+ function newestTsMtimeMs(dir) {
366
+ let newest = 0;
367
+ let entries;
368
+ try {
369
+ entries = fs.readdirSync(dir, { withFileTypes: true });
370
+ }
371
+ catch {
372
+ return newest;
373
+ }
374
+ for (const e of entries) {
375
+ const full = path.join(dir, e.name);
376
+ if (e.isDirectory()) {
377
+ newest = Math.max(newest, newestTsMtimeMs(full));
378
+ }
379
+ else if (e.isFile() && e.name.endsWith(".ts")) {
380
+ try {
381
+ newest = Math.max(newest, fs.statSync(full).mtimeMs);
382
+ }
383
+ catch {
384
+ // unreadable file: ignore
385
+ }
386
+ }
387
+ }
388
+ return newest;
389
+ }
390
+ // One-line, token-free description of the active credential path (§6.4). For a
391
+ // user session it adds the display name and how much access-token runway is
392
+ // left, so an operator debugging "why am I getting 401s" sees the mode AND
393
+ // whether the access token is near expiry (the auto-refresh trigger).
394
+ function describeAuthMode(auth) {
395
+ if (auth.mode === "none") {
396
+ return "none (not logged in; run `mla login`)";
397
+ }
398
+ if (auth.mode === "shared-key") {
399
+ return "shared-key (internal key; no user identity)";
400
+ }
401
+ const who = auth.user.displayName || auth.user.id;
402
+ const ms = Date.parse(auth.accessExpiresAt) - Date.now();
403
+ let runway;
404
+ if (Number.isNaN(ms)) {
405
+ runway = "expiry unknown";
406
+ }
407
+ else if (ms <= 0) {
408
+ // Access token expired. Auto-refresh fires on the next control call, but it
409
+ // is NOT guaranteed: if the refresh token was revoked or rotated away
410
+ // server-side, the refresh 401s and `mla login` is the only recovery. Do not
411
+ // promise a refresh that may not happen (the old "(will auto-refresh)" lie
412
+ // sent operators into a no-op loop). When the refresh window has also lapsed
413
+ // locally, say so plainly.
414
+ const refreshMs = Date.parse(auth.refreshExpiresAt) - Date.now();
415
+ runway =
416
+ !Number.isNaN(refreshMs) && refreshMs > 0
417
+ ? "access token expired (auto-refresh, else `mla login`)"
418
+ : "session expired; run `mla login`";
419
+ }
420
+ else {
421
+ const hours = Math.floor(ms / (60 * 60 * 1000));
422
+ runway = hours < 48 ? `access expires ~${hours}h` : `access expires ~${Math.floor(hours / 24)}d`;
423
+ }
424
+ return `user-token (${who}; ${runway})`;
425
+ }
426
+ async function runDoctor(argv) {
427
+ assertNoArgs(argv);
428
+ const checks = [];
429
+ let cfg = null;
430
+ try {
431
+ cfg = (0, config_1.readConfig)();
432
+ checks.push({ ok: true, label: "cli-config.json present", detail: cfg.controlUrl });
433
+ // §6.4: surface which credential path is active at a glance. Info-level (does
434
+ // not fail the doctor): all three modes are valid states. NEVER prints a
435
+ // token, only the mode and, for a user session, the display name + expiry.
436
+ checks.push({
437
+ ok: true,
438
+ label: "auth.mode",
439
+ detail: describeAuthMode(cfg.auth),
440
+ level: "info",
441
+ });
442
+ }
443
+ catch (e) {
444
+ checks.push({ ok: false, label: "cli-config.json present", detail: e.message });
445
+ console.log("Doctor:");
446
+ for (const c of checks)
447
+ console.log(fmt(c));
448
+ return 1;
449
+ }
450
+ // 1. control reachable
451
+ const health = await (0, http_1.ping)(cfg, "/internal/v1/health");
452
+ checks.push({
453
+ ok: health.ok,
454
+ label: "control reachable (GET /internal/v1/health)",
455
+ detail: health.ok ? cfg.controlUrl : health.error,
456
+ });
457
+ // Folder = workspace (T1.1): the workspace this directory is bound to comes
458
+ // from the nearest `.meetless.json` marker, never cli-config. doctor is a
459
+ // diagnostic that must run from any directory, so resolution is best-effort:
460
+ // null means "this folder is not activated" and the workspace-scoped probes
461
+ // (whoami, kb/health) report that instead of calling control with no id.
462
+ const markerWorkspaceId = (0, workspace_1.tryResolveWorkspaceId)();
463
+ // 2. whoami
464
+ //
465
+ // KB curation (proposal v2.3 §9.3, T39) extends this in two ways:
466
+ //
467
+ // - When cli-config carries `actorUserId`, pass it on the query so the
468
+ // control resolver verifies (workspaceId, actorUserId) is a member AND
469
+ // has role OWNER. Owner-only is the v1 ACL (proposal §9 footnote #13);
470
+ // no per-call --actor flag, no KB_CURATE scope yet.
471
+ // - When `actorUserId` is missing, doctor still calls whoami (legacy
472
+ // agent_review path stays green) but flags the missing field as RED so
473
+ // the operator sees the gap before they try to run `mla kb ...`.
474
+ let whoami = null;
475
+ const actorUserId = (cfg.actorUserId || "").trim();
476
+ if (!actorUserId) {
477
+ checks.push({
478
+ ok: false,
479
+ label: "cli-config.actorUserId present",
480
+ detail: "missing. KB curation commands stamp this onto every outbox event. " +
481
+ "Re-run `mla init --actor <id>` or edit cli-config.json directly.",
482
+ });
483
+ }
484
+ if (health.ok && !markerWorkspaceId) {
485
+ checks.push({
486
+ ok: false,
487
+ label: "workspace activated (.meetless.json)",
488
+ detail: `no marker at or above ${process.cwd()}. whoami + KB probes need a ` +
489
+ "workspace binding; run `mla activate` here.",
490
+ });
491
+ }
492
+ if (health.ok && markerWorkspaceId) {
493
+ try {
494
+ const whoamiPath = actorUserId
495
+ ? `/internal/v1/whoami?workspaceId=${encodeURIComponent(markerWorkspaceId)}&actorUserId=${encodeURIComponent(actorUserId)}`
496
+ : `/internal/v1/whoami?workspaceId=${encodeURIComponent(markerWorkspaceId)}`;
497
+ whoami = await (0, http_1.get)(cfg, whoamiPath, 6000);
498
+ checks.push({ ok: true, label: `token valid + workspace resolves`, detail: whoami?.workspace?.slug ?? whoami?.workspace?.id });
499
+ checks.push({
500
+ ok: !!whoami?.actor,
501
+ label: "actor resolves (workspace member)",
502
+ detail: whoami?.actor?.displayName ?? whoami?.actor?.email,
503
+ });
504
+ // Owner-only ACL gate (§9.3). Fall back to actorIsMember when the
505
+ // server is the pre-§9.3 build that does not emit actorIsOwner so the
506
+ // doctor does not flap during the rollout.
507
+ if (actorUserId) {
508
+ const isOwner = typeof whoami?.actorIsOwner === "boolean"
509
+ ? whoami.actorIsOwner
510
+ : whoami?.actor?.role === "OWNER";
511
+ checks.push({
512
+ ok: !!isOwner,
513
+ label: "actor is workspace OWNER (KB curation §9.3)",
514
+ detail: isOwner
515
+ ? `role=${whoami?.actor?.role ?? "OWNER"}`
516
+ : `role=${whoami?.actor?.role ?? "UNKNOWN"}; KB curation requires OWNER.`,
517
+ });
518
+ }
519
+ checks.push({
520
+ ok: !!whoami?.caseKindAgentReviewSeeded,
521
+ label: "CaseKind 'agent_review' seeded",
522
+ });
523
+ }
524
+ catch (e) {
525
+ const err = e;
526
+ checks.push({
527
+ ok: false,
528
+ label: "whoami (token + workspace + actor)",
529
+ detail: `HTTP ${err.status ?? "?"}: ${err.message.slice(0, 120)}`,
530
+ });
531
+ }
532
+ }
533
+ // 3. intel reachable
534
+ let intelReachable = false;
535
+ if (cfg.intelUrl) {
536
+ try {
537
+ const res = await fetch(`${cfg.intelUrl}/health`, { signal: AbortSignal.timeout(5000) });
538
+ intelReachable = res.ok;
539
+ checks.push({ ok: res.ok, label: "intel reachable (GET /health)", detail: cfg.intelUrl });
540
+ }
541
+ catch (e) {
542
+ checks.push({ ok: false, label: "intel reachable (GET /health)", detail: e.message });
543
+ }
544
+ }
545
+ // 3b. KB substrate health probe (proposal v2.3 §9.3, T39).
546
+ //
547
+ // GET /internal/v1/kb/health?workspaceId=<ws> returns
548
+ // outboxConsumerLagSec (warn > 300s)
549
+ // hardDeletePendingMaxAgeSec (warn > 86400s)
550
+ // warnings: [...]
551
+ //
552
+ // Both surface as RED (not info) when the threshold trips: a lagging
553
+ // outbox means `mla kb show` audit-trails miss recent events; a stuck
554
+ // HARD_DELETE_PENDING doc means the phase-2 Weaviate-delete IntelJob is
555
+ // wedged and the doc body is half-purged. The endpoint is rolling out
556
+ // alongside the CLI; until the intel build with /kb/health lands, the
557
+ // doctor logs an info row instead of failing.
558
+ if (intelReachable && cfg.intelUrl && markerWorkspaceId) {
559
+ try {
560
+ const url = `${cfg.intelUrl}/internal/v1/kb/health?workspaceId=${encodeURIComponent(markerWorkspaceId)}`;
561
+ const res = await fetch(url, {
562
+ method: "GET",
563
+ headers: { Authorization: `Bearer ${cfg.controlToken}` },
564
+ signal: AbortSignal.timeout(8000),
565
+ });
566
+ if (res.status === 404) {
567
+ checks.push({
568
+ ok: true,
569
+ level: "info",
570
+ label: "KB health probe: endpoint absent on this intel build (skipped)",
571
+ });
572
+ }
573
+ else if (!res.ok) {
574
+ checks.push({
575
+ ok: false,
576
+ label: "KB health probe (GET /internal/v1/kb/health)",
577
+ detail: `HTTP ${res.status}`,
578
+ });
579
+ }
580
+ else {
581
+ const body = await res.json();
582
+ const lag = body?.outboxConsumerLagSec ?? null;
583
+ const pending = body?.hardDeletePendingCount ?? 0;
584
+ const pendingAge = body?.hardDeletePendingMaxAgeSec ?? null;
585
+ const warnings = Array.isArray(body?.warnings) ? body.warnings : [];
586
+ const lagOk = !warnings.some((w) => w.includes("outbox consumer lag"));
587
+ const pendingOk = !warnings.some((w) => w.includes("HARD_DELETE_PENDING"));
588
+ checks.push({
589
+ ok: lagOk,
590
+ label: "KB outbox consumer lag (warn > 5min)",
591
+ detail: lag === null
592
+ ? "no unconsumed KB outbox rows"
593
+ : `${lag}s lag, ${body?.outboxUnconsumedCount ?? 0} unconsumed`,
594
+ });
595
+ checks.push({
596
+ ok: pendingOk,
597
+ label: "HARD_DELETE_PENDING age (warn > 24h)",
598
+ detail: pending === 0
599
+ ? "0 docs pending"
600
+ : `${pending} doc(s) pending, oldest ${pendingAge ?? "?"}s`,
601
+ });
602
+ }
603
+ }
604
+ catch (e) {
605
+ checks.push({
606
+ ok: false,
607
+ label: "KB health probe (GET /internal/v1/kb/health)",
608
+ detail: e.message,
609
+ });
610
+ }
611
+ }
612
+ // 4. hooks registered in ~/.claude/settings.json
613
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
614
+ if (fs.existsSync(settingsPath)) {
615
+ try {
616
+ const s = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
617
+ const installedCmds = new Set();
618
+ for (const ev of Object.keys(s.hooks ?? {})) {
619
+ for (const entry of s.hooks[ev] ?? []) {
620
+ for (const h of entry.hooks ?? []) {
621
+ if (h?.type === "command" && typeof h.command === "string")
622
+ installedCmds.add(h.command);
623
+ }
624
+ }
625
+ }
626
+ for (const ev of exports.REQUIRED_HOOK_EVENTS) {
627
+ const present = (s.hooks?.[ev] ?? []).length > 0;
628
+ checks.push({ ok: present, label: `hook event ${ev} registered` });
629
+ }
630
+ for (const ev of OPTIONAL_HOOK_EVENTS) {
631
+ const present = (s.hooks?.[ev] ?? []).length > 0;
632
+ checks.push({
633
+ ok: true,
634
+ label: `hook event ${ev}: ${present ? "ON" : "OFF (opt-out flag)"}`,
635
+ level: "info",
636
+ });
637
+ }
638
+ void installedCmds;
639
+ }
640
+ catch (e) {
641
+ checks.push({ ok: false, label: "~/.claude/settings.json parseable", detail: e.message });
642
+ }
643
+ }
644
+ else {
645
+ checks.push({ ok: false, label: "~/.claude/settings.json present" });
646
+ }
647
+ // 5. /mla skill installed
648
+ const skillPath = path.join(os.homedir(), ".claude", "skills", "mla", "SKILL.md");
649
+ checks.push({ ok: fs.existsSync(skillPath), label: "/mla skill installed", detail: skillPath });
650
+ // 5b. Meetless MCP server registered for Claude Code.
651
+ //
652
+ // `mla init`/`mla rewire` write a user-scope server into ~/.claude.json
653
+ // (top-level mcpServers.meetless -> `mla mcp`, auto-loads with no approval
654
+ // prompt, scopes per-repo via CLAUDE_PROJECT_DIR). Without it the
655
+ // meetless__* tools never appear and the consult-governed-memory-first rule
656
+ // is unenforceable: a silent, invisible failure, so it earns a doctor check.
657
+ //
658
+ // The dogfood setup instead pins a project-scope `.mcp.json` (often above the
659
+ // git root, with a custom env block) -- a deliberate hand-rolled override, not
660
+ // drift. So if the user-scope entry is absent we walk up from cwd looking for
661
+ // a `.mcp.json` that registers the server before going RED, and only suggest
662
+ // `mla rewire` when neither path has it.
663
+ {
664
+ const hasServer = (obj) => {
665
+ const s = obj?.mcpServers?.[wire_1.MCP_SERVER_KEY];
666
+ return !!s && typeof s === "object" && typeof s.command === "string";
667
+ };
668
+ const claudeJsonPath = path.join(os.homedir(), ".claude.json");
669
+ let userScope = false;
670
+ if (fs.existsSync(claudeJsonPath)) {
671
+ try {
672
+ userScope = hasServer(JSON.parse(fs.readFileSync(claudeJsonPath, "utf8")));
673
+ }
674
+ catch {
675
+ // unparseable ~/.claude.json: treat as not-registered; the wire step
676
+ // reports the parse failure separately.
677
+ userScope = false;
678
+ }
679
+ }
680
+ if (userScope) {
681
+ checks.push({
682
+ ok: true,
683
+ label: "Meetless MCP server registered (user scope)",
684
+ detail: claudeJsonPath,
685
+ });
686
+ }
687
+ else {
688
+ // Walk up from cwd to the filesystem root, stopping if we pass home, and
689
+ // look for a project-scope `.mcp.json` that registers the server.
690
+ let projectMcp = null;
691
+ let dir = process.cwd();
692
+ const home = os.homedir();
693
+ for (;;) {
694
+ const candidate = path.join(dir, ".mcp.json");
695
+ if (fs.existsSync(candidate)) {
696
+ try {
697
+ if (hasServer(JSON.parse(fs.readFileSync(candidate, "utf8")))) {
698
+ projectMcp = candidate;
699
+ break;
700
+ }
701
+ }
702
+ catch {
703
+ // ignore an unparseable .mcp.json and keep walking up
704
+ }
705
+ }
706
+ const parent = path.dirname(dir);
707
+ if (parent === dir || dir === home)
708
+ break;
709
+ dir = parent;
710
+ }
711
+ if (projectMcp) {
712
+ checks.push({
713
+ ok: true,
714
+ label: "Meetless MCP server registered (project scope)",
715
+ detail: projectMcp,
716
+ level: "info",
717
+ });
718
+ }
719
+ else {
720
+ checks.push({
721
+ ok: false,
722
+ label: "Meetless MCP server registered",
723
+ detail: `not found in ${claudeJsonPath}; run \`mla rewire\` then restart Claude Code`,
724
+ });
725
+ }
726
+ }
727
+ }
728
+ // 6. mlaPath resolves + executable
729
+ let mlaExec = false;
730
+ try {
731
+ fs.accessSync(cfg.mlaPath, fs.constants.X_OK);
732
+ mlaExec = true;
733
+ }
734
+ catch {
735
+ mlaExec = false;
736
+ }
737
+ checks.push({ ok: mlaExec, label: "mlaPath resolves + executable", detail: cfg.mlaPath });
738
+ // 6b. build freshness (stale-dist footgun).
739
+ //
740
+ // The mla binary runs from dist/cli.js. If a source change landed in src/
741
+ // but `pnpm build` was not re-run, the binary silently executes stale
742
+ // compiled code: the exact footgun behind "I changed it but mla still does
743
+ // the old thing", which historically burned real debugging time because
744
+ // `mla --version` reported a frozen string that never moved across builds.
745
+ // doctor.js runs from dist/commands/, so dist/cli.js is one level up and src/
746
+ // is two levels up. Compare the compiled cli.js mtime against the newest
747
+ // src/*.ts mtime; RED when dist lags. Skipped (info) when src/ is absent (a
748
+ // published install ships only dist/).
749
+ {
750
+ const distCli = path.join(__dirname, "..", "cli.js");
751
+ const srcDir = path.join(__dirname, "..", "..", "src");
752
+ if (!fs.existsSync(srcDir)) {
753
+ checks.push({
754
+ ok: true,
755
+ level: "info",
756
+ label: "build freshness: src/ absent (published install), not checked",
757
+ });
758
+ }
759
+ else {
760
+ let distMs = 0;
761
+ try {
762
+ distMs = fs.statSync(distCli).mtimeMs;
763
+ }
764
+ catch {
765
+ distMs = 0;
766
+ }
767
+ const srcMs = newestTsMtimeMs(srcDir);
768
+ const stale = distMs === 0 || srcMs > distMs;
769
+ let freshDetail = distMs ? `built ${new Date(distMs).toISOString()}` : "dist/cli.js missing";
770
+ try {
771
+ const bi = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "build-info.json"), "utf8"));
772
+ freshDetail = `${bi.sha ?? "?"}${bi.dirty ? "-dirty" : ""}, built ${bi.builtAt ?? freshDetail}`;
773
+ }
774
+ catch {
775
+ // no build-info.json (pre-stamp build): fall back to dist mtime detail
776
+ }
777
+ checks.push({
778
+ ok: !stale,
779
+ label: "build fresh (dist newer than src)",
780
+ detail: stale
781
+ ? `STALE; run \`pnpm build\` in meetless-cli/packages/cli (newest src ${new Date(srcMs).toISOString()} > dist ${distMs ? new Date(distMs).toISOString() : "missing"})`
782
+ : freshDetail,
783
+ });
784
+ }
785
+ }
786
+ // 7a. flock present in PATH. flush.sh + common.sh use `flock -n 9` for
787
+ // hook concurrency; macOS does not ship it. If flock is missing the hook
788
+ // pipeline silently no-ops (`|| exit 0` after the failed flock call), so
789
+ // events queue forever and never reach control. `brew install flock`.
790
+ let flockPath = null;
791
+ try {
792
+ flockPath = (0, child_process_1.execSync)("command -v flock", { encoding: "utf8" }).trim() || null;
793
+ }
794
+ catch {
795
+ flockPath = null;
796
+ }
797
+ checks.push({
798
+ ok: !!flockPath,
799
+ label: "flock available on PATH (hook concurrency primitive)",
800
+ detail: flockPath ?? "missing. Run `brew install flock` (macOS) or `apt-get install util-linux` (Linux).",
801
+ });
802
+ // 7b. hook scripts present + executable
803
+ for (const f of exports.REQUIRED_HOOKS) {
804
+ const p = path.join(config_1.HOOKS_DIR, f);
805
+ const ok = fs.existsSync(p);
806
+ let exec = false;
807
+ if (ok) {
808
+ try {
809
+ fs.accessSync(p, fs.constants.X_OK);
810
+ exec = true;
811
+ }
812
+ catch {
813
+ exec = false;
814
+ }
815
+ }
816
+ checks.push({ ok: ok && (f.endsWith(".sh") ? exec : true), label: `hook script ${f} installed`, detail: p });
817
+ }
818
+ for (const f of exports.OPTIONAL_HOOKS) {
819
+ const p = path.join(config_1.HOOKS_DIR, f);
820
+ const present = fs.existsSync(p);
821
+ checks.push({
822
+ ok: true,
823
+ label: `hook script ${f}: ${present ? "present" : "skipped (--no-post-tool-use)"}`,
824
+ level: "info",
825
+ });
826
+ }
827
+ // 7c. hook content-drift check (generic byte comparison vs shipped template).
828
+ //
829
+ // Compares every installed hook in ~/.meetless/hooks/ against the template
830
+ // THIS binary would install (the exact bytes `mla rewire` copies). Any
831
+ // difference means the operator upgraded the binary but never re-ran
832
+ // `mla rewire`, so the live hooks lag the code.
833
+ //
834
+ // This REPLACES the old per-file marker-substring scan (Epoch 27/35), which
835
+ // only covered flush.sh + session-start.sh and silently missed edits to
836
+ // common.sh / user-prompt-submit.sh -- exactly the gap that let the
837
+ // 2026-05-31 turn_index fix ship in the binary while the installed hooks
838
+ // kept writing `turn_index: null` under a GREEN doctor. Byte comparison
839
+ // covers all seven hook files for free and needs no per-edit maintenance.
840
+ // Missing files are NOT drift: presence is reported by the per-hook checks
841
+ // above, and a legit opt-out (post-tool-use.sh under --no-post-tool-use)
842
+ // would otherwise false-positive.
843
+ try {
844
+ const drift = (0, wire_1.checkHookDrift)();
845
+ const stale = drift.drifted;
846
+ checks.push({
847
+ ok: stale.length === 0,
848
+ label: stale.length === 0
849
+ ? "hook scripts match shipped templates"
850
+ : `hook scripts stale: ${stale.join(", ")}`,
851
+ detail: stale.length === 0
852
+ ? undefined
853
+ : "installed copy differs from this binary's template; run `mla rewire` to refresh",
854
+ });
855
+ for (const e of drift.errors) {
856
+ checks.push({
857
+ ok: false,
858
+ label: `hook drift check: ${e.file}`,
859
+ detail: e.error,
860
+ });
861
+ }
862
+ }
863
+ catch (e) {
864
+ // Template dir not found (published install lacking dist/hooks-template).
865
+ // Not a failure of the operator's wiring; surface as info, not red.
866
+ checks.push({
867
+ ok: true,
868
+ level: "info",
869
+ label: `hook drift check skipped (template not found): ${e.message}`,
870
+ });
871
+ }
872
+ // 8. queue depth
873
+ const qd = (0, spool_1.queueDepth)();
874
+ checks.push({
875
+ ok: true,
876
+ level: "info",
877
+ label: `queue depth: ${qd.sessions} active sessions, ${qd.events} events, ${qd.orphans} orphan snapshots, oldest age ${qd.oldestAgeSec ?? "n/a"}s`,
878
+ });
879
+ // 8b. stale-session debt (read-only). queueDepth() counts every `.jsonl` and
880
+ // draining snapshot as an "active session", but a session that drained its
881
+ // events and never cleanly finalized leaves behind a 0-byte spool plus
882
+ // `.lock`/`.turn`/`.repoPath`/`.gitBaseline` sidecars that NOTHING in the hook
883
+ // pipeline ever removes -- so "active sessions" inflates without bound (the
884
+ // phantom count this surfaces). reapQueue({dryRun}) counts what `mla flush
885
+ // --gc` WOULD remove without touching disk (doctor must never mutate). It only
886
+ // ever counts sessions with zero undelivered work and idle past the age gate,
887
+ // so a non-zero number here is always safe to reap.
888
+ const debt = (0, spool_1.reapQueue)({ dryRun: true });
889
+ if (debt.reaped.length > 0) {
890
+ checks.push({
891
+ ok: true,
892
+ level: "info",
893
+ label: `stale-session debt: ${debt.reaped.length} reapable session(s), ${debt.removedFiles} dead file(s) idle > 24h. Run \`mla flush --gc\` to clear.`,
894
+ });
895
+ }
896
+ // 9. per-folder activation (opt-in capture gate). Informational: a dormant
897
+ // folder is a valid state, not a failure. Walk up from the cwd where the
898
+ // operator invoked `mla doctor` (the same walk the bash gate does from a
899
+ // hook's $PWD). When found, surface the marker path and the resolved
900
+ // workspace so the operator can confirm THIS folder will actually capture.
901
+ const activation = (0, activation_1.findActivation)(process.cwd());
902
+ if (activation) {
903
+ // Folder = workspace (T1.1): the marker IS the source of the workspace for
904
+ // both capture and the CLI. A marker with no usable workspaceId is a stale
905
+ // binding the operator must repair with `mla activate`.
906
+ const wsDetail = activation.workspaceId
907
+ ? `workspace ${activation.workspaceId}` +
908
+ (activation.workspaceName ? ` (${activation.workspaceName})` : "")
909
+ : "marker present but workspaceId missing; re-run `mla activate` to repair";
910
+ checks.push({
911
+ ok: !!activation.workspaceId,
912
+ level: activation.workspaceId ? "info" : undefined,
913
+ label: `folder activated: ${activation.path} -> ${wsDetail}`,
914
+ });
915
+ if (activation.parseError) {
916
+ checks.push({
917
+ ok: false,
918
+ label: ` marker JSON unparseable (${activation.parseError.slice(0, 80)}); re-run \`mla activate\` to repair the binding`,
919
+ });
920
+ }
921
+ }
922
+ else {
923
+ checks.push({
924
+ ok: true,
925
+ level: "info",
926
+ label: `folder NOT activated (no .meetless.json at or above ${process.cwd()}). Run \`mla activate\` here to capture sessions.`,
927
+ });
928
+ }
929
+ // 10. session capture (mute / unmute). Reported DISTINCTLY from the
930
+ // workspace-binding lifecycle above: activation is about the folder, capture
931
+ // is about THIS session. A folder can be activated while this session is muted.
932
+ checks.push(sessionCaptureCheck(process.env.CLAUDE_CODE_SESSION_ID, config_1.SESSION_GATE_DIR));
933
+ // 11. CE0 interception store posture + the three deny-enablement gates (R1 deny-admission
934
+ // preconditions, §10.1 step 1(d)).
935
+ //
936
+ // The store posture gates the notes-location deny pilot: the local interception schema is at the
937
+ // version this binary expects, and the CE0 store is in WAL with foreign keys enforced (so a
938
+ // PreToolUse read never blocks on a writer and an evaluation row can never orphan its attempt or
939
+ // rule version). On top of posture, the three deny-enablement gates report whether a would-be deny
940
+ // is admissible right now: P0.58 (MLA is the sole effective PreToolUse Write/Edit authority), P0.63
941
+ // (the active scope's attested forbidden root resolves), and P0.60 (denies are honestly accounted,
942
+ // none stuck recorded-but-unemitted). The store is created on the first intercepted tool call, so
943
+ // its absence is informational, not red; P0.58 always runs because admissibility never depends on
944
+ // any row existing yet, while P0.63/P0.60 read the store and so run only once it exists.
945
+ {
946
+ const ce0Path = (0, evidence_1.defaultCe0StorePath)();
947
+ if (fs.existsSync(ce0Path)) {
948
+ let ce0;
949
+ try {
950
+ ce0 = (0, ce0_store_1.openCe0Store)(ce0Path);
951
+ // P0.15: a deliberate full-database integrity scan FIRST. If the local SQLite authority is
952
+ // corrupt the PreToolUse hook silently fails open, so doctor must surface that RED before
953
+ // reading anything else; the version/wal/fk/accounting/path-root reads below are meaningless
954
+ // on an unsound store, so they only run once integrity holds.
955
+ const integrity = ce0IntegrityCheck(ce0QuickCheckResult(ce0));
956
+ checks.push(integrity);
957
+ if (integrity.ok) {
958
+ const version = ce0.db.pragma("user_version", { simple: true });
959
+ const journalMode = ce0.db.pragma("journal_mode", { simple: true });
960
+ const foreignKeys = ce0.db.pragma("foreign_keys", { simple: true });
961
+ const busyTimeout = ce0.db.pragma("busy_timeout", { simple: true });
962
+ checks.push(schemaVersionCheck(version, interception_schema_1.CE0_INTERCEPTION_SCHEMA_VERSION));
963
+ checks.push(walModeCheck(journalMode));
964
+ checks.push(foreignKeysCheck(foreignKeys));
965
+ checks.push(busyTimeoutCheck(busyTimeout));
966
+ // P0.60: honest deny-emission accounting (never RED, just surfaces the count).
967
+ checks.push(denyEmissionAccountingCheck((0, interception_store_1.countDenyDecisionsAwaitingEmission)(ce0)));
968
+ // Historical fail-open visibility: any DENY-ceiling violation that ever passed un-governed
969
+ // (RULE_ENFORCEMENT_UNAVAILABLE, decision 5). Info, not RED: the append-only ledger must not
970
+ // pin doctor RED forever, so the count itself is the loud alert deny-admission.ts promises.
971
+ checks.push(failOpenEnforcementCheck((0, interception_store_1.countFailOpenEnforcementViolations)(ce0)));
972
+ // P0.63: the attested forbidden root resolves for the active scope. Mirror the enforce seam:
973
+ // read the active scope's LIVE notes-location version, and if one is attested, resolve its
974
+ // forbidden root against the active runtime root exactly as the seam would at a would-be deny.
975
+ // With no LIVE version the path-root gate is simply inactive (informational, not red).
976
+ const runtimeRoot = (0, runtime_scope_1.resolveActiveRuntimeScopeId)();
977
+ const liveVersion = (0, local_rule_version_repo_1.getLiveLocalRuleVersion)(ce0, runtimeRoot, attest_notes_location_1.NOTES_LOCATION_RULE_ID);
978
+ if (liveVersion) {
979
+ const payload = JSON.parse(liveVersion.rulePayload);
980
+ checks.push(attestedPathRootCheck((0, deny_admission_1.resolveAttestedPathRoot)({
981
+ configuredRelativeForbiddenPath: payload.compliance.config.forbiddenRootRelativePath,
982
+ activeRuntimeProjectRoot: runtimeRoot,
983
+ })));
984
+ }
985
+ else {
986
+ checks.push({
987
+ ok: true,
988
+ level: "info",
989
+ label: `no LIVE notes-location rule attested in this scope (${runtimeRoot}); path-root gate inactive`,
990
+ });
991
+ }
992
+ }
993
+ }
994
+ catch (e) {
995
+ checks.push({ ok: false, label: "CE0 interception store posture", detail: e.message });
996
+ }
997
+ finally {
998
+ if (ce0)
999
+ (0, ce0_store_1.closeCe0Store)(ce0);
1000
+ }
1001
+ }
1002
+ else {
1003
+ checks.push({
1004
+ ok: true,
1005
+ level: "info",
1006
+ label: `CE0 interception store not yet created (${ce0Path}); posture checks run after the first intercepted tool call`,
1007
+ });
1008
+ }
1009
+ checks.push(managedPreToolUseHookCheck((0, live_input_authority_1.resolveLiveInputAuthority)()));
1010
+ }
1011
+ console.log("Doctor:");
1012
+ for (const c of checks)
1013
+ console.log(fmt(c));
1014
+ const code = doctorExitCode(checks);
1015
+ if (code !== 0) {
1016
+ console.error("\nDoctor RED. Fix the failing rows before dogfooding.");
1017
+ return code;
1018
+ }
1019
+ console.log("\nDoctor GREEN.");
1020
+ return 0;
1021
+ }