@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,444 @@
1
+ "use strict";
2
+ // Agent-failure telemetry: the central sanitizer, the local deadletter, and the
3
+ // F8 (telemetry-upload-failed) detector. The TS half of the
4
+ // notes/20260608-agent-failure-telemetry-sentry-proposal.md §8 contracts.
5
+ //
6
+ // Three §8 invariants live here:
7
+ // INV-TELEMETRY-METADATA-CLASSIFICATION -> sanitizeTelemetry (allowlist, fail closed)
8
+ // INV-DEADLETTER-SAFETY -> appendDeadletter / flushDeadletter (0600, bounded, TTL, backoff)
9
+ // F8 detector -> recordTelemetryUploadFailure
10
+ //
11
+ // The Python twin is intel app/core/telemetry_sanitizer.py + failure_telemetry.py.
12
+ // The two MUST classify the same field names the same way; a field that hashes on
13
+ // one plane and ships raw on the other defeats the whole contract.
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.FAILURE_KB_WRITE_BLOCKED = exports.FAILURE_TELEMETRY_UPLOAD_FAILED = exports.DEADLETTER_MAX_ATTEMPTS = exports.DEADLETTER_TTL_MS = exports.DEADLETTER_MAX_BYTES = exports.DEADLETTER_MAX_RECORDS = exports.TELEMETRY_SCHEMA_VERSION = void 0;
49
+ exports.hashBasename = hashBasename;
50
+ exports.sanitizeTelemetry = sanitizeTelemetry;
51
+ exports.deadletterPath = deadletterPath;
52
+ exports.appendDeadletter = appendDeadletter;
53
+ exports.loadDeadletter = loadDeadletter;
54
+ exports.isAttemptDue = isAttemptDue;
55
+ exports.flushDeadletter = flushDeadletter;
56
+ exports.recordTelemetryUploadFailure = recordTelemetryUploadFailure;
57
+ exports.recordKbWriteBlocked = recordKbWriteBlocked;
58
+ const crypto = __importStar(require("crypto"));
59
+ const fs = __importStar(require("fs"));
60
+ const os = __importStar(require("os"));
61
+ const path = __importStar(require("path"));
62
+ const config_1 = require("./config");
63
+ // Schema version stamped on every deadletter record. Bump when the record shape
64
+ // changes so a post-upgrade replay can drop records it no longer understands
65
+ // (INV-DEADLETTER-SAFETY: schema-versioned).
66
+ exports.TELEMETRY_SCHEMA_VERSION = 1;
67
+ // ---------------------------------------------------------------------------
68
+ // INV-TELEMETRY-METADATA-CLASSIFICATION: the single central sanitizer.
69
+ //
70
+ // One choke point. Every telemetry / deadletter payload passes through this
71
+ // before it leaves the process. The table is an ALLOWLIST: any field not
72
+ // classified here is dropped, so an unclassified field fails closed to
73
+ // "not sent" rather than open to "sent raw" (An, 2026-06-08). The next detector
74
+ // author who adds a field MUST add a row, which is the point.
75
+ // ---------------------------------------------------------------------------
76
+ // Always sent verbatim: low-cardinality enums and the cross-system join keys.
77
+ // workspace_id / session_id are server-resolved identifiers, not free text.
78
+ const ALWAYS_SEND = new Set([
79
+ "failure_class",
80
+ "severity",
81
+ "trace_id",
82
+ "surface",
83
+ "workspace_id",
84
+ "session_id",
85
+ "schema_version",
86
+ "release",
87
+ "mla_version",
88
+ "platform",
89
+ "environment",
90
+ "window",
91
+ "tool", // tool NAME (e.g. "Bash", "Edit") is low-cardinality, not content
92
+ "posture",
93
+ "reason_code",
94
+ "status", // HTTP status code: low-cardinality integer, never content
95
+ "http_status",
96
+ "status_code",
97
+ ]);
98
+ // Never sent: content and anything that can carry customer / security context.
99
+ // Full paths and raw text are the canonical leak vectors (§8: a path like
100
+ // customers/acme/security-audit/oauth-migration.ts is itself sensitive).
101
+ const NEVER_SEND = new Set([
102
+ "full_path",
103
+ "path",
104
+ "file_path",
105
+ "query",
106
+ "query_text",
107
+ "answer",
108
+ "answer_text",
109
+ "prompt",
110
+ "prompt_text",
111
+ "tool_output",
112
+ "tool_input",
113
+ "doc_body",
114
+ "content",
115
+ "code",
116
+ "stdout",
117
+ "stderr",
118
+ "message_text",
119
+ ]);
120
+ // File basenames are hashed (not sent raw): keeps the path out of the clear but
121
+ // preserves cross-event correlation via a stable digest. A full path smuggled
122
+ // under one of these keys is reduced to its basename first, then hashed.
123
+ const BASENAME_KEYS = new Set([
124
+ "file_basename",
125
+ "basename",
126
+ "file_name",
127
+ "filename",
128
+ "target_basename",
129
+ ]);
130
+ // Nested metadata bags that are themselves sanitized field-by-field. Lets a
131
+ // detector pass a `metadata_only_context: { candidate_count: 3 }` without the
132
+ // whole object being dropped, while every field inside it still runs the table.
133
+ const CONTAINER_KEYS = new Set(["context", "metadata_only_context", "metadata"]);
134
+ // Numeric metadata: counts, durations, lengths, byte sizes, attempt counters.
135
+ // Only sent when the value is actually a number, so a string smuggled under a
136
+ // "*_count" key still drops.
137
+ const NUMERIC_METADATA_RE = /(?:_count|_ms|_seconds|_secs|_duration|_len|_length|_bytes|_attempts?|_size|_total|_index|_n)$|^(?:count|attempts|duration_ms|n|index|total)$/i;
138
+ // Hash a basename into a stable, non-reversible short digest. A full path is
139
+ // reduced to its last segment first (split on both separators so a Windows path
140
+ // is handled), so even a misclassified full path cannot leak its parent dirs.
141
+ function hashBasename(value) {
142
+ const base = value.split(/[\\/]/).filter(Boolean).pop() ?? value;
143
+ const digest = crypto.createHash("sha256").update(base).digest("hex").slice(0, 16);
144
+ return `b_${digest}`;
145
+ }
146
+ function isScalar(v) {
147
+ return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
148
+ }
149
+ function isPlainObject(v) {
150
+ return v !== null && typeof v === "object" && !Array.isArray(v);
151
+ }
152
+ // The central sanitizer. Drop-by-default. Returns a new object; the input is
153
+ // never mutated. Recurses one level into known container keys so a detector's
154
+ // metadata bag is classified field-by-field, not waved through.
155
+ function sanitizeTelemetry(event) {
156
+ const out = {};
157
+ for (const [key, value] of Object.entries(event)) {
158
+ if (NEVER_SEND.has(key))
159
+ continue;
160
+ if (CONTAINER_KEYS.has(key) && isPlainObject(value)) {
161
+ out[key] = sanitizeTelemetry(value);
162
+ continue;
163
+ }
164
+ if (BASENAME_KEYS.has(key) && typeof value === "string") {
165
+ out[`${key}_hash`] = hashBasename(value);
166
+ continue;
167
+ }
168
+ if (ALWAYS_SEND.has(key) && isScalar(value)) {
169
+ out[key] = value;
170
+ continue;
171
+ }
172
+ if (NUMERIC_METADATA_RE.test(key) && typeof value === "number") {
173
+ out[key] = value;
174
+ continue;
175
+ }
176
+ // Allowlist fail-closed: anything not classified above is dropped.
177
+ }
178
+ return out;
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // INV-DEADLETTER-SAFETY: the bounded, mode-0600, TTL-governed local store.
182
+ // ---------------------------------------------------------------------------
183
+ exports.DEADLETTER_MAX_RECORDS = 500;
184
+ exports.DEADLETTER_MAX_BYTES = 1_000_000; // 1 MB ceiling
185
+ exports.DEADLETTER_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
186
+ exports.DEADLETTER_MAX_ATTEMPTS = 5;
187
+ const DEADLETTER_BACKOFF_BASE_MS = 60_000; // 1 minute
188
+ const DEADLETTER_BACKOFF_CAP_MS = 6 * 60 * 60 * 1000; // 6 hours
189
+ const DEADLETTER_FILE_MODE = 0o600;
190
+ const DEADLETTER_DIR_MODE = 0o700;
191
+ // Mirror config.ts HOME resolution but compute per-call so a test can redirect
192
+ // the whole store to a temp dir via MEETLESS_HOME without module-cache games.
193
+ function homeDir(env) {
194
+ return env.MEETLESS_HOME || path.join(os.homedir(), ".meetless");
195
+ }
196
+ function deadletterPath(env = process.env) {
197
+ return path.join(homeDir(env), "telemetry-deadletter.jsonl");
198
+ }
199
+ function readRecords(file) {
200
+ let raw;
201
+ try {
202
+ raw = fs.readFileSync(file, "utf8");
203
+ }
204
+ catch {
205
+ return [];
206
+ }
207
+ const out = [];
208
+ for (const line of raw.split("\n")) {
209
+ const trimmed = line.trim();
210
+ if (!trimmed)
211
+ continue;
212
+ try {
213
+ const rec = JSON.parse(trimmed);
214
+ // Drop records from an older/newer schema we cannot safely replay.
215
+ if (rec && rec.schema_version === exports.TELEMETRY_SCHEMA_VERSION)
216
+ out.push(rec);
217
+ }
218
+ catch {
219
+ // A torn line (partial write) is skipped, not fatal.
220
+ }
221
+ }
222
+ return out;
223
+ }
224
+ function notExpired(rec, now) {
225
+ const exp = Date.parse(rec.expires_at);
226
+ return Number.isNaN(exp) ? true : now < exp;
227
+ }
228
+ // Enforce the count + byte ceilings by dropping OLDEST records first. The file
229
+ // is append-mostly, so position == age; keeping the tail keeps the freshest.
230
+ function enforceBounds(records) {
231
+ let kept = records;
232
+ if (kept.length > exports.DEADLETTER_MAX_RECORDS) {
233
+ kept = kept.slice(kept.length - exports.DEADLETTER_MAX_RECORDS);
234
+ }
235
+ let serialized = kept.map((r) => JSON.stringify(r)).join("\n");
236
+ while (kept.length > 0 && Buffer.byteLength(serialized, "utf8") > exports.DEADLETTER_MAX_BYTES) {
237
+ kept = kept.slice(1);
238
+ serialized = kept.map((r) => JSON.stringify(r)).join("\n");
239
+ }
240
+ return kept;
241
+ }
242
+ // Atomic-ish rewrite with locked-down perms. Writes the whole set (the store is
243
+ // low-volume: it only grows on an upload failure), then chmods to 0600 even when
244
+ // the file pre-existed with looser perms.
245
+ function writeRecords(file, records) {
246
+ const dir = path.dirname(file);
247
+ fs.mkdirSync(dir, { recursive: true, mode: DEADLETTER_DIR_MODE });
248
+ const body = records.length > 0 ? records.map((r) => JSON.stringify(r)).join("\n") + "\n" : "";
249
+ fs.writeFileSync(file, body, { mode: DEADLETTER_FILE_MODE });
250
+ try {
251
+ fs.chmodSync(file, DEADLETTER_FILE_MODE);
252
+ }
253
+ catch {
254
+ // best-effort on platforms without POSIX perms
255
+ }
256
+ }
257
+ // Append one failure event. Sanitizes BEFORE write so a replayed record can
258
+ // never carry content the live path would have stripped (§8, An Decision 9).
259
+ // Never throws: a telemetry write must not break the user's command.
260
+ function appendDeadletter(event, opts = {}) {
261
+ const env = opts.env ?? process.env;
262
+ const now = opts.now ?? Date.now();
263
+ try {
264
+ const sanitized = sanitizeTelemetry(event);
265
+ const record = {
266
+ schema_version: exports.TELEMETRY_SCHEMA_VERSION,
267
+ created_at: new Date(now).toISOString(),
268
+ expires_at: new Date(now + exports.DEADLETTER_TTL_MS).toISOString(),
269
+ attempts: 0,
270
+ last_attempt_at: null,
271
+ failure_class: String(sanitized.failure_class ?? "unknown"),
272
+ event: sanitized,
273
+ };
274
+ const file = deadletterPath(env);
275
+ const existing = readRecords(file).filter((r) => notExpired(r, now));
276
+ const next = enforceBounds([...existing, record]);
277
+ writeRecords(file, next);
278
+ return record;
279
+ }
280
+ catch {
281
+ return null;
282
+ }
283
+ }
284
+ // Non-expired records, oldest first. Drops expired records as a side effect
285
+ // (rewrites the file) so a stale record cannot be retried forever.
286
+ function loadDeadletter(opts = {}) {
287
+ const env = opts.env ?? process.env;
288
+ const now = opts.now ?? Date.now();
289
+ const file = deadletterPath(env);
290
+ const all = readRecords(file);
291
+ const live = all.filter((r) => notExpired(r, now));
292
+ if (live.length !== all.length) {
293
+ try {
294
+ writeRecords(file, live);
295
+ }
296
+ catch {
297
+ // best effort
298
+ }
299
+ }
300
+ return live;
301
+ }
302
+ // Exponential backoff gate. A fresh record (0 attempts) is due immediately;
303
+ // thereafter the next attempt waits base * 2^(attempts-1), capped, measured from
304
+ // the last attempt. Keeps a down backend from being hammered on every CLI run.
305
+ function isAttemptDue(rec, now) {
306
+ if (rec.attempts <= 0 || !rec.last_attempt_at)
307
+ return true;
308
+ const last = Date.parse(rec.last_attempt_at);
309
+ if (Number.isNaN(last))
310
+ return true;
311
+ const delay = Math.min(DEADLETTER_BACKOFF_BASE_MS * 2 ** (rec.attempts - 1), DEADLETTER_BACKOFF_CAP_MS);
312
+ return now >= last + delay;
313
+ }
314
+ // Replay the deadletter through `upload`. Per record:
315
+ // - expired -> dropped
316
+ // - not yet due (backoff) -> kept as-is
317
+ // - upload ok -> dropped (sent)
318
+ // - upload fails -> attempts++, last_attempt_at=now; at MAX_ATTEMPTS dropped
319
+ // Respects the telemetry kill switch (nothing leaves the machine when off) and
320
+ // never throws.
321
+ async function flushDeadletter(opts) {
322
+ const env = opts.env ?? process.env;
323
+ const now = opts.now ?? Date.now();
324
+ const result = { sent: 0, dropped: 0, kept: 0 };
325
+ if ((0, config_1.telemetryDisabled)(env)) {
326
+ // Kill switch on: do not forward anything. Leave the local store intact.
327
+ return result;
328
+ }
329
+ const file = deadletterPath(env);
330
+ const all = readRecords(file);
331
+ const survivors = [];
332
+ for (const rec of all) {
333
+ if (!notExpired(rec, now)) {
334
+ result.dropped++;
335
+ continue;
336
+ }
337
+ if (!isAttemptDue(rec, now)) {
338
+ survivors.push(rec);
339
+ result.kept++;
340
+ continue;
341
+ }
342
+ try {
343
+ await opts.upload(rec);
344
+ result.sent++;
345
+ }
346
+ catch {
347
+ const attempts = rec.attempts + 1;
348
+ if (attempts >= exports.DEADLETTER_MAX_ATTEMPTS) {
349
+ result.dropped++;
350
+ }
351
+ else {
352
+ survivors.push({
353
+ ...rec,
354
+ attempts,
355
+ last_attempt_at: new Date(now).toISOString(),
356
+ });
357
+ result.kept++;
358
+ }
359
+ }
360
+ }
361
+ try {
362
+ writeRecords(file, survivors);
363
+ }
364
+ catch {
365
+ // best effort
366
+ }
367
+ return result;
368
+ }
369
+ // ---------------------------------------------------------------------------
370
+ // F8 detector: telemetry upload itself failed.
371
+ //
372
+ // Per INV-SENTRY-NOISE-BUDGET, F8 routes to the LOCAL deadletter + a local
373
+ // warning, NOT to Sentry (unless the backend side later observes it). The CLI
374
+ // has no PostHog key by design, so an F8 on the user's machine is recorded
375
+ // locally; the deadletter IS the store. This is also the self-host posture: on a
376
+ // user-owned backend nothing leaves the machine.
377
+ // ---------------------------------------------------------------------------
378
+ exports.FAILURE_TELEMETRY_UPLOAD_FAILED = "telemetry_upload_failed";
379
+ // Record an F8 failure to the deadletter. Returns the record (for tests/logging)
380
+ // or null when the kill switch is on or the write failed. Never throws.
381
+ function recordTelemetryUploadFailure(ctx, opts = {}) {
382
+ const env = opts.env ?? process.env;
383
+ if ((0, config_1.telemetryDisabled)(env))
384
+ return null;
385
+ const context = {};
386
+ if (typeof ctx.status === "number")
387
+ context.status = ctx.status;
388
+ if (ctx.reasonCode)
389
+ context.reason_code = ctx.reasonCode;
390
+ const event = {
391
+ failure_class: exports.FAILURE_TELEMETRY_UPLOAD_FAILED,
392
+ severity: "warning",
393
+ surface: ctx.surface ?? "mla-cli",
394
+ metadata_only_context: context,
395
+ };
396
+ if (ctx.traceId)
397
+ event.trace_id = ctx.traceId;
398
+ if (ctx.workspaceId)
399
+ event.workspace_id = ctx.workspaceId;
400
+ if (ctx.sessionId)
401
+ event.session_id = ctx.sessionId;
402
+ return appendDeadletter(event, opts);
403
+ }
404
+ // ---------------------------------------------------------------------------
405
+ // F5 detector: a KB write the agent attempted was blocked.
406
+ //
407
+ // The canonical signal is `mla kb add` exiting non-zero because the actor is not
408
+ // the workspace owner (the §13.14 owner-only ACL): the agent tried to write a
409
+ // lesson down and could not. Like F8 this routes to the LOCAL deadletter and a
410
+ // local warning, NOT to Sentry directly (intel SENTRY_ROUTE[F5] = IF_REPEATED;
411
+ // a one-off block is expected friction, a recurrence is the alert). The CLI has
412
+ // no PostHog key by design, so the deadletter IS the store on the user's machine.
413
+ //
414
+ // The failure_class string and "warning" severity MUST match the intel twin
415
+ // (app/core/failure_telemetry.py: FailureClass.KB_WRITE_BLOCKED = "kb_write_blocked",
416
+ // _DEFAULT_SEVERITY[KB_WRITE_BLOCKED] = WARNING); they are the cross-plane key.
417
+ // ---------------------------------------------------------------------------
418
+ exports.FAILURE_KB_WRITE_BLOCKED = "kb_write_blocked";
419
+ // Record an F5 (kb-write-blocked) failure to the deadletter. Returns the record
420
+ // (for tests/logging) or null when the kill switch is on or the write failed.
421
+ // Never throws: detecting a blocked write must not itself break the command.
422
+ function recordKbWriteBlocked(ctx, opts = {}) {
423
+ const env = opts.env ?? process.env;
424
+ if ((0, config_1.telemetryDisabled)(env))
425
+ return null;
426
+ const context = {};
427
+ if (typeof ctx.status === "number")
428
+ context.status = ctx.status;
429
+ if (ctx.reasonCode)
430
+ context.reason_code = ctx.reasonCode;
431
+ const event = {
432
+ failure_class: exports.FAILURE_KB_WRITE_BLOCKED,
433
+ severity: "warning",
434
+ surface: ctx.surface ?? "mla-cli",
435
+ metadata_only_context: context,
436
+ };
437
+ if (ctx.traceId)
438
+ event.trace_id = ctx.traceId;
439
+ if (ctx.workspaceId)
440
+ event.workspace_id = ctx.workspaceId;
441
+ if (ctx.sessionId)
442
+ event.session_id = ctx.sessionId;
443
+ return appendDeadletter(event, opts);
444
+ }
@@ -0,0 +1,200 @@
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.computeRepoFingerprint = computeRepoFingerprint;
37
+ exports.captureGitEvidence = captureGitEvidence;
38
+ const child_process_1 = require("child_process");
39
+ const crypto = __importStar(require("crypto"));
40
+ function gitRun(repo, args) {
41
+ const r = (0, child_process_1.spawnSync)("git", ["-C", repo, ...args], { encoding: "utf8" });
42
+ return {
43
+ ok: r.status === 0,
44
+ stdout: r.stdout || "",
45
+ stderr: r.stderr || "",
46
+ };
47
+ }
48
+ // A NON-identifying, one-way fingerprint of the repository a run executed in,
49
+ // for analytics attribution (spec section 3.7 / T1.10). Hashes the git remote
50
+ // URL (the stable repo identity) when present, else the repo top-level path, so
51
+ // two runs in the same checkout share a fingerprint WITHOUT the raw remote URL
52
+ // or absolute path ever leaving the machine (INV-POSTHOG-PII-1 forbids a raw
53
+ // repoPath). The "r_" prefix + sha256-slice mirror store.ts machineId(). Returns
54
+ // null outside a git repo or when git is unavailable; attribution then carries a
55
+ // null repoFingerprint rather than a fabricated value. Computed once per run at
56
+ // bootstrap, never per event (it shells out to git).
57
+ function computeRepoFingerprint(repo = process.cwd()) {
58
+ const remote = gitRun(repo, ["config", "--get", "remote.origin.url"]);
59
+ let seed = remote.ok ? remote.stdout.trim() : "";
60
+ if (!seed) {
61
+ const top = gitRun(repo, ["rev-parse", "--show-toplevel"]);
62
+ seed = top.ok ? top.stdout.trim() : "";
63
+ }
64
+ if (!seed)
65
+ return null;
66
+ return "r_" + crypto.createHash("sha256").update(seed).digest("hex").slice(0, 24);
67
+ }
68
+ function parseDiffStat(out) {
69
+ let filesChanged = 0;
70
+ let insertions = 0;
71
+ let deletions = 0;
72
+ const m = out.match(/(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?\(\+\))?(?:,\s+(\d+)\s+deletions?\(-\))?/);
73
+ if (m) {
74
+ filesChanged = parseInt(m[1] || "0", 10);
75
+ insertions = parseInt(m[2] || "0", 10);
76
+ deletions = parseInt(m[3] || "0", 10);
77
+ }
78
+ return { filesChanged, insertions, deletions };
79
+ }
80
+ function parsePorcelain(out) {
81
+ const trackedModified = [];
82
+ const staged = [];
83
+ const untracked = [];
84
+ const deleted = [];
85
+ const renamed = [];
86
+ const lines = out.split("\n");
87
+ for (const line of lines) {
88
+ if (!line)
89
+ continue;
90
+ const xy = line.slice(0, 2);
91
+ const rest = line.slice(3);
92
+ const X = xy[0];
93
+ const Y = xy[1];
94
+ if (X === "?" && Y === "?") {
95
+ untracked.push(rest);
96
+ continue;
97
+ }
98
+ if (X === "R" || Y === "R") {
99
+ const parts = rest.split(" -> ");
100
+ if (parts.length === 2) {
101
+ renamed.push({ from: parts[0], to: parts[1] });
102
+ }
103
+ continue;
104
+ }
105
+ if (X === "D" || Y === "D") {
106
+ deleted.push(rest);
107
+ }
108
+ if (X !== " " && X !== "?") {
109
+ staged.push(rest);
110
+ }
111
+ if (Y !== " " && Y !== "?") {
112
+ trackedModified.push(rest);
113
+ }
114
+ }
115
+ return { trackedModified, staged, untracked, deleted, renamed };
116
+ }
117
+ // Subtract the session-start baseline from the finalize-time porcelain so the
118
+ // review attributes only what the SESSION touched, not ambient dirty state the
119
+ // working tree already carried before the agent started (the 2026-05-31 dogfood
120
+ // bug: a pre-existing `.claude/scheduled_tasks.lock` deletion was blamed on the
121
+ // run). Comparison is exact-line (porcelain `XY␣path`): a line byte-identical to
122
+ // the baseline is ambient and dropped; a line whose status code CHANGED since
123
+ // the baseline means the session acted on it, so it survives.
124
+ //
125
+ // Known, accepted limitation: a file already dirty at session start that the
126
+ // session edits FURTHER with the SAME status code (e.g. " M" -> " M") is treated
127
+ // as ambient. Closing that needs per-path content hashing; the dominant bug
128
+ // (files the session never touched at all) is what this fixes.
129
+ function subtractBaseline(currentPorcelain, baselinePorcelain) {
130
+ const baselineLines = new Set(baselinePorcelain.split("\n").filter((l) => l.length > 0));
131
+ return currentPorcelain
132
+ .split("\n")
133
+ .filter((l) => l.length > 0 && !baselineLines.has(l))
134
+ .join("\n");
135
+ }
136
+ function captureGitEvidence(repo, baselinePorcelain) {
137
+ const errors = [];
138
+ const branchRes = gitRun(repo, ["branch", "--show-current"]);
139
+ const branch = branchRes.ok ? branchRes.stdout.trim() : "";
140
+ if (!branchRes.ok)
141
+ errors.push("branch:" + branchRes.stderr.trim().slice(0, 100));
142
+ const topRes = gitRun(repo, ["rev-parse", "--show-toplevel"]);
143
+ const topLevel = topRes.ok ? topRes.stdout.trim() : "";
144
+ if (!topRes.ok)
145
+ errors.push("toplevel:" + topRes.stderr.trim().slice(0, 100));
146
+ const logRes = gitRun(repo, ["log", "-1", "--oneline"]);
147
+ const lastCommit = logRes.ok ? logRes.stdout.trim() : "";
148
+ // -c core.quotePath=false keeps non-ASCII paths in raw UTF-8 instead of the
149
+ // default C-style octal-quoted form. Without this, a Vietnamese filename
150
+ // like `Lỗi-không-tìm-thấy.ts` comes back as
151
+ // `"L\341\273\227i-kh\303\264ng-t\303\254m-th\341\272\245y.ts"`, which then
152
+ // flows into trackedModified / staged / untracked verbatim, kills the
153
+ // worker's classifier match, and renders as garbled text in `mla review`.
154
+ const porcelainRes = gitRun(repo, [
155
+ "-c",
156
+ "core.quotePath=false",
157
+ "status",
158
+ "--porcelain=v1",
159
+ ]);
160
+ // A non-null baseline (even an empty string) switches us to session-scoped
161
+ // attribution; `undefined`/`null` preserves the original whole-tree behavior.
162
+ const scoped = baselinePorcelain !== undefined && baselinePorcelain !== null;
163
+ const effectivePorcelain = scoped && porcelainRes.ok ? subtractBaseline(porcelainRes.stdout, baselinePorcelain) : porcelainRes.stdout;
164
+ const { trackedModified, staged, untracked, deleted, renamed } = porcelainRes.ok
165
+ ? parsePorcelain(effectivePorcelain)
166
+ : { trackedModified: [], staged: [], untracked: [], deleted: [], renamed: [] };
167
+ if (!porcelainRes.ok)
168
+ errors.push("status:" + porcelainRes.stderr.trim().slice(0, 100));
169
+ // When scoped, recompute the diff stats over only the session-attributed
170
+ // paths so insertion/deletion counts and filesChanged exclude ambient churn.
171
+ // `git diff --stat -- <paths>` with an empty pathspec would list everything,
172
+ // so an empty session set is reported as a zeroed stat (the honest answer).
173
+ const ZERO_STAT = { filesChanged: 0, insertions: 0, deletions: 0 };
174
+ const renamedPaths = renamed.flatMap((r) => [r.from, r.to]);
175
+ const unstagedPaths = Array.from(new Set([...trackedModified, ...renamedPaths]));
176
+ const stagedPaths = Array.from(new Set([...staged, ...renamedPaths]));
177
+ let diffStat;
178
+ let diffStatCached;
179
+ if (scoped) {
180
+ diffStat = unstagedPaths.length > 0 ? parseDiffStat(gitRun(repo, ["diff", "--stat", "--", ...unstagedPaths]).stdout) : { ...ZERO_STAT };
181
+ diffStatCached = stagedPaths.length > 0 ? parseDiffStat(gitRun(repo, ["diff", "--cached", "--stat", "--", ...stagedPaths]).stdout) : { ...ZERO_STAT };
182
+ }
183
+ else {
184
+ diffStat = parseDiffStat(gitRun(repo, ["diff", "--stat"]).stdout);
185
+ diffStatCached = parseDiffStat(gitRun(repo, ["diff", "--cached", "--stat"]).stdout);
186
+ }
187
+ return {
188
+ branch,
189
+ topLevel,
190
+ lastCommit,
191
+ trackedModified,
192
+ staged,
193
+ untracked,
194
+ deleted,
195
+ renamed,
196
+ diffStat,
197
+ diffStatCached,
198
+ errors,
199
+ };
200
+ }