@mmnto/cli 1.96.0 → 1.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mmnto/cli
2
2
 
3
- Command-line interface for [Totem](https://github.com/mmnto-ai/totem), a persistent memory and context layer for AI coding agents. Installs the `totem` binary.
3
+ Command-line interface for [Totem](https://github.com/mmnto-ai/totem) a local-first, file-anchored substrate that makes AI-agent work queryable, enforceable, and derivable in your codebase. Installs the `totem` binary.
4
4
 
5
5
  ## Install
6
6
 
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Recall-compliance sensor for `totem doctor --compliance` (ADR-029 minimal
3
+ * slice, mmnto-ai/totem#2362).
4
+ *
5
+ * ADR-029 § 1 "Passive Log Analysis" specifies the telemetry pair by design:
6
+ * `.totem/.search-log.jsonl` (produced by the MCP `search_knowledge` tool) +
7
+ * git commit timestamps — write interception was explicitly rejected as too
8
+ * intrusive. The Compliance Rate (§ 3) is the % of coding sessions in which a
9
+ * `search_knowledge` call preceded the session's first commit.
10
+ *
11
+ * Commit-granularity caveat (§ 1, ruled in on #2362): a search landing between
12
+ * a file write and its commit still counts as compliant. That is inherent to
13
+ * the ADR's chosen design and acceptable for a warning-threshold sensor
14
+ * (Tenet 13) — it is a caveat on the readout, not a rename of the metric.
15
+ *
16
+ * Sensor-not-gate (Tenet 13): this command is a pure readout. It never throws,
17
+ * never sets a non-zero exit code, and is not part of the gating `--strict`
18
+ * suite — it only ever reports.
19
+ *
20
+ * Session model (ADR-029 § 2, verbatim): a coding session is "contiguous
21
+ * `search_knowledge` calls and git commits occurring within a rolling 2-hour
22
+ * window" — ONE merged event stream. An intervening commit extends a session
23
+ * exactly like a search does; search-only clusters with window-attached commits
24
+ * are NOT equivalent and were reworked out (2026-07-15 panel fold, codex
25
+ * architecture lens, verified against the ADR text).
26
+ *
27
+ * Why the rate is repo-wide only (same fold): commits carry no seat identity —
28
+ * SHA + timestamp — so a per-seat Compliance Rate would have to guess which
29
+ * seat's search "owns" a commit, fabricating attribution (Tenet 4).
30
+ * `agent_source` renders as an attribution-coverage diagnostic (entry counts
31
+ * per seat; `unattributed` = the ~420 pre-schema entries + hookless sessions),
32
+ * explicitly not a per-seat rate. The per-seat rate activates when a
33
+ * commit-side identity primitive exists (ADR-078 boundary / commit-stamped
34
+ * session ids). `session_id` is stamped by the producer for that same forward
35
+ * join and is deliberately unused in this windowing — there is nothing
36
+ * commit-side to join it against yet.
37
+ *
38
+ * Known precision limit: a later `git rebase` rewrites commit timestamps, which
39
+ * retroactively shifts the 2-hour windows a past run computed against — so a
40
+ * historical Compliance Rate is only as stable as the commit timestamps it read.
41
+ * This is inherent to the passive-log design (§ 1) and is not corrected here.
42
+ */
43
+ /** A parsed `.search-log.jsonl` entry — only the fields the metric needs. */
44
+ export interface ComplianceLogEntry {
45
+ timestamp: string;
46
+ agent_source: string | null;
47
+ session_id: string | null;
48
+ }
49
+ /**
50
+ * The git-history SEAM: a commit's sha + ISO timestamp. The doctor section
51
+ * supplies this from real git via `readCommitRecords`; tests supply literal
52
+ * arrays (no git spawn, no temp dirs).
53
+ */
54
+ export interface CommitRecord {
55
+ sha: string;
56
+ timestamp: string;
57
+ }
58
+ /** Rate numerator/denominator. */
59
+ export interface RateStat {
60
+ /** Counted sessions (coding sessions — windows containing ≥1 commit). */
61
+ n: number;
62
+ /** Of `n`, how many had a search precede the window's first commit. */
63
+ compliant: number;
64
+ }
65
+ export interface ComplianceReport {
66
+ /** Repo-wide Compliance Rate over merged-stream § 2 windows. */
67
+ overall: RateStat;
68
+ /**
69
+ * Attribution coverage — entry counts per `agent_source` bucket, sorted by
70
+ * name (`unattributed` = null/pre-schema). A diagnostic, NOT compliance:
71
+ * commits carry no seat identity, so per-seat rates are non-identifiable
72
+ * until a commit-side join primitive exists.
73
+ */
74
+ coverage: Array<{
75
+ bucket: string;
76
+ entries: number;
77
+ }>;
78
+ /**
79
+ * Windows that searched but never committed. Not a coding session per
80
+ * ADR-029 § 3, so excluded from the denominator — but surfaced so a
81
+ * search-heavy/commit-light stretch is visible, not hidden.
82
+ */
83
+ searchOnlySessions: number;
84
+ /** Raw count of parsed entries with no `agent_source` (the unattributed backlog). */
85
+ unattributedEntries: number;
86
+ }
87
+ export interface ParseResult {
88
+ entries: ComplianceLogEntry[];
89
+ /** Lines that were non-empty but failed JSON.parse or timestamp validation. */
90
+ malformedCount: number;
91
+ }
92
+ /**
93
+ * Parse the raw `.search-log.jsonl` contents. A malformed/corrupt line (bad
94
+ * JSON, or a missing/unparseable timestamp) is skipped and counted — the
95
+ * command never crashes on a partial write or a hand-edit (Tenet 13 sensor
96
+ * pattern: record + continue).
97
+ */
98
+ export declare function parseSearchLog(content: string): ParseResult;
99
+ /**
100
+ * Compute the Compliance Rate report from parsed log entries + the commit seam.
101
+ *
102
+ * 1. Merge searches + commits into ONE repo-wide event timeline (§ 2 verbatim:
103
+ * sessions are contiguous searches AND commits in a rolling 2-hour window —
104
+ * an intervening commit extends a session exactly like a search does).
105
+ * 2. Roll windows: a new session starts when the gap from the previous event
106
+ * exceeds 2 hours.
107
+ * 3. Score each window containing ≥1 commit: compliant iff its earliest search
108
+ * precedes its earliest commit (a commit-only window is non-compliant by
109
+ * construction). Search-only windows are excluded from the denominator (not
110
+ * coding sessions per § 3) but surfaced.
111
+ * 4. Coverage: entry counts per `agent_source` — a diagnostic, never a rate
112
+ * (commits carry no seat identity; see the header).
113
+ */
114
+ export declare function computeCompliance(entries: ComplianceLogEntry[], commits: CommitRecord[]): ComplianceReport;
115
+ /**
116
+ * Format a rate for display. Below `MIN_SESSIONS_FOR_RATE` counted sessions
117
+ * (including n=0), a percentage is meaningless — render the honest
118
+ * "insufficient data (n=x)" instead (ruled in on #2362: keep the metric name
119
+ * "Compliance Rate", surface low-n honestly rather than an over-precise %).
120
+ */
121
+ export declare function formatRate(stat: RateStat): string;
122
+ export interface ComplianceCliOptions {
123
+ /** Test seam — production callers omit and the command uses `process.cwd()`. */
124
+ cwdForTest?: string;
125
+ /** Test seam — inject raw log contents instead of reading `.search-log.jsonl`. */
126
+ logContentForTest?: string;
127
+ /** Test seam — inject commits instead of spawning git. */
128
+ commitsForTest?: CommitRecord[];
129
+ }
130
+ /**
131
+ * CLI entry — renders the Compliance Rate readout. Pure sensor: never throws
132
+ * for a compliance verdict, never sets a non-zero exit code (Tenet 13). Absent
133
+ * log file → the doctor `skip` idiom pointing at the MCP wiring, NOT a fail and
134
+ * NOT 0%.
135
+ */
136
+ export declare function doctorComplianceCliCommand(options?: ComplianceCliOptions): Promise<void>;
137
+ //# sourceMappingURL=doctor-compliance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doctor-compliance.d.ts","sourceRoot":"","sources":["../../src/commands/doctor-compliance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAqBH,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,kCAAkC;AAClC,MAAM,WAAW,QAAQ;IACvB,yEAAyE;IACzE,CAAC,EAAE,MAAM,CAAC;IACV,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,gEAAgE;IAChE,OAAO,EAAE,QAAQ,CAAC;IAClB;;;;;OAKG;IACH,QAAQ,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACrD;;;;OAIG;IACH,kBAAkB,EAAE,MAAM,CAAC;IAC3B,qFAAqF;IACrF,mBAAmB,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,+EAA+E;IAC/E,cAAc,EAAE,MAAM,CAAC;CACxB;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,WAAW,CAkC3D;AAUD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,YAAY,EAAE,GACtB,gBAAgB,CAuDlB;AAID;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAIjD;AAmCD,MAAM,WAAW,oBAAoB;IACnC,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,0DAA0D;IAC1D,cAAc,CAAC,EAAE,YAAY,EAAE,CAAC;CACjC;AAED;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAC9C,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,IAAI,CAAC,CA6Ef"}
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Recall-compliance sensor for `totem doctor --compliance` (ADR-029 minimal
3
+ * slice, mmnto-ai/totem#2362).
4
+ *
5
+ * ADR-029 § 1 "Passive Log Analysis" specifies the telemetry pair by design:
6
+ * `.totem/.search-log.jsonl` (produced by the MCP `search_knowledge` tool) +
7
+ * git commit timestamps — write interception was explicitly rejected as too
8
+ * intrusive. The Compliance Rate (§ 3) is the % of coding sessions in which a
9
+ * `search_knowledge` call preceded the session's first commit.
10
+ *
11
+ * Commit-granularity caveat (§ 1, ruled in on #2362): a search landing between
12
+ * a file write and its commit still counts as compliant. That is inherent to
13
+ * the ADR's chosen design and acceptable for a warning-threshold sensor
14
+ * (Tenet 13) — it is a caveat on the readout, not a rename of the metric.
15
+ *
16
+ * Sensor-not-gate (Tenet 13): this command is a pure readout. It never throws,
17
+ * never sets a non-zero exit code, and is not part of the gating `--strict`
18
+ * suite — it only ever reports.
19
+ *
20
+ * Session model (ADR-029 § 2, verbatim): a coding session is "contiguous
21
+ * `search_knowledge` calls and git commits occurring within a rolling 2-hour
22
+ * window" — ONE merged event stream. An intervening commit extends a session
23
+ * exactly like a search does; search-only clusters with window-attached commits
24
+ * are NOT equivalent and were reworked out (2026-07-15 panel fold, codex
25
+ * architecture lens, verified against the ADR text).
26
+ *
27
+ * Why the rate is repo-wide only (same fold): commits carry no seat identity —
28
+ * SHA + timestamp — so a per-seat Compliance Rate would have to guess which
29
+ * seat's search "owns" a commit, fabricating attribution (Tenet 4).
30
+ * `agent_source` renders as an attribution-coverage diagnostic (entry counts
31
+ * per seat; `unattributed` = the ~420 pre-schema entries + hookless sessions),
32
+ * explicitly not a per-seat rate. The per-seat rate activates when a
33
+ * commit-side identity primitive exists (ADR-078 boundary / commit-stamped
34
+ * session ids). `session_id` is stamped by the producer for that same forward
35
+ * join and is deliberately unused in this windowing — there is nothing
36
+ * commit-side to join it against yet.
37
+ *
38
+ * Known precision limit: a later `git rebase` rewrites commit timestamps, which
39
+ * retroactively shifts the 2-hour windows a past run computed against — so a
40
+ * historical Compliance Rate is only as stable as the commit timestamps it read.
41
+ * This is inherent to the passive-log design (§ 1) and is not corrected here.
42
+ */
43
+ const TAG = 'Compliance';
44
+ /** ADR-029 § 2 rolling session window. */
45
+ const WINDOW_MS = 2 * 60 * 60 * 1000;
46
+ /**
47
+ * Below this many counted sessions in a bucket, a percentage is statistically
48
+ * meaningless — render "insufficient data (n=x)" instead of a rate.
49
+ */
50
+ const MIN_SESSIONS_FOR_RATE = 5;
51
+ /** How far back to read commit history for the metric (bounded to keep the git read cheap). */
52
+ const MAX_COMMITS = 2000;
53
+ /** Bucket name for events with no `agent_source` (legacy + hookless). */
54
+ const UNATTRIBUTED = 'unattributed';
55
+ // ─── Pure: parse ────────────────────────────────────────
56
+ /**
57
+ * Parse the raw `.search-log.jsonl` contents. A malformed/corrupt line (bad
58
+ * JSON, or a missing/unparseable timestamp) is skipped and counted — the
59
+ * command never crashes on a partial write or a hand-edit (Tenet 13 sensor
60
+ * pattern: record + continue).
61
+ */
62
+ export function parseSearchLog(content) {
63
+ const entries = [];
64
+ let malformedCount = 0;
65
+ for (const raw of content.split('\n')) {
66
+ const line = raw.trim();
67
+ if (line.length === 0)
68
+ continue;
69
+ let obj;
70
+ try {
71
+ obj = JSON.parse(line);
72
+ // totem-context: intentional malformed-line tolerance — a corrupt/partial JSONL line is counted (malformedCount surfaces it in the readout) and skipped; the sensor records + continues (Tenet 13), and crashing the doctor on one torn append would be the real degradation.
73
+ }
74
+ catch {
75
+ malformedCount++;
76
+ continue;
77
+ }
78
+ if (typeof obj !== 'object' || obj === null) {
79
+ malformedCount++;
80
+ continue;
81
+ }
82
+ const rec = obj;
83
+ const timestamp = rec.timestamp;
84
+ if (typeof timestamp !== 'string' || !Number.isFinite(Date.parse(timestamp))) {
85
+ malformedCount++;
86
+ continue;
87
+ }
88
+ entries.push({
89
+ timestamp,
90
+ // Absent / non-string / explicit null all normalize to the unattributed bucket.
91
+ agent_source: typeof rec.agent_source === 'string' ? rec.agent_source : null,
92
+ session_id: typeof rec.session_id === 'string' ? rec.session_id : null,
93
+ });
94
+ }
95
+ return { entries, malformedCount };
96
+ }
97
+ /**
98
+ * Compute the Compliance Rate report from parsed log entries + the commit seam.
99
+ *
100
+ * 1. Merge searches + commits into ONE repo-wide event timeline (§ 2 verbatim:
101
+ * sessions are contiguous searches AND commits in a rolling 2-hour window —
102
+ * an intervening commit extends a session exactly like a search does).
103
+ * 2. Roll windows: a new session starts when the gap from the previous event
104
+ * exceeds 2 hours.
105
+ * 3. Score each window containing ≥1 commit: compliant iff its earliest search
106
+ * precedes its earliest commit (a commit-only window is non-compliant by
107
+ * construction). Search-only windows are excluded from the denominator (not
108
+ * coding sessions per § 3) but surfaced.
109
+ * 4. Coverage: entry counts per `agent_source` — a diagnostic, never a rate
110
+ * (commits carry no seat identity; see the header).
111
+ */
112
+ export function computeCompliance(entries, commits) {
113
+ // Coverage diagnostic (per-seat entry counts; null → unattributed).
114
+ const coverageMap = new Map();
115
+ let unattributedEntries = 0;
116
+ for (const e of entries) {
117
+ const bucket = e.agent_source ?? UNATTRIBUTED;
118
+ if (e.agent_source === null)
119
+ unattributedEntries++;
120
+ coverageMap.set(bucket, (coverageMap.get(bucket) ?? 0) + 1);
121
+ }
122
+ // Merged § 2 timeline. Entry timestamps are parse-validated finite; commit
123
+ // timestamps come from the seam and are filtered here.
124
+ const events = entries.map((e) => ({
125
+ ms: Date.parse(e.timestamp),
126
+ kind: 'search',
127
+ }));
128
+ for (const c of commits) {
129
+ const ms = Date.parse(c.timestamp);
130
+ if (Number.isFinite(ms))
131
+ events.push({ ms, kind: 'commit' });
132
+ }
133
+ events.sort((a, b) => a.ms - b.ms);
134
+ // Rolling windows + scoring.
135
+ const overall = { n: 0, compliant: 0 };
136
+ let searchOnlySessions = 0;
137
+ const scoreWindow = (window) => {
138
+ if (window.length === 0)
139
+ return;
140
+ const firstCommit = window.find((ev) => ev.kind === 'commit');
141
+ if (firstCommit === undefined) {
142
+ // Searched, never committed — not a coding session (§ 3).
143
+ searchOnlySessions++;
144
+ return;
145
+ }
146
+ const firstSearch = window.find((ev) => ev.kind === 'search');
147
+ overall.n++;
148
+ // Strictly before: § 3 says "preceded" — an equal-timestamp tie does not
149
+ // demonstrate the search informed the commit, so it does not credit.
150
+ if (firstSearch !== undefined && firstSearch.ms < firstCommit.ms)
151
+ overall.compliant++;
152
+ };
153
+ let window = [];
154
+ for (const ev of events) {
155
+ if (window.length === 0 || ev.ms - window[window.length - 1].ms <= WINDOW_MS) {
156
+ window.push(ev);
157
+ }
158
+ else {
159
+ scoreWindow(window);
160
+ window = [ev];
161
+ }
162
+ }
163
+ scoreWindow(window);
164
+ const coverage = [...coverageMap.entries()]
165
+ .map(([bucket, count]) => ({ bucket, entries: count }))
166
+ .sort((a, b) => a.bucket.localeCompare(b.bucket));
167
+ return { overall, coverage, searchOnlySessions, unattributedEntries };
168
+ }
169
+ // ─── Pure: render helper ────────────────────────────────
170
+ /**
171
+ * Format a rate for display. Below `MIN_SESSIONS_FOR_RATE` counted sessions
172
+ * (including n=0), a percentage is meaningless — render the honest
173
+ * "insufficient data (n=x)" instead (ruled in on #2362: keep the metric name
174
+ * "Compliance Rate", surface low-n honestly rather than an over-precise %).
175
+ */
176
+ export function formatRate(stat) {
177
+ if (stat.n < MIN_SESSIONS_FOR_RATE)
178
+ return `insufficient data (n=${stat.n})`;
179
+ const pct = ((stat.compliant / stat.n) * 100).toFixed(0);
180
+ return `${pct}% (n=${stat.n})`;
181
+ }
182
+ // ─── Git supplier (impure — the seam's production source) ───
183
+ /**
184
+ * Read recent commits as `(sha, timestamp)[]` for the metric. Best-effort:
185
+ * any git failure (no repo, no commits, git absent) degrades to an empty array
186
+ * so the sensor renders "insufficient data" rather than crashing. `%cI` is the
187
+ * committer date in strict ISO-8601 — the same instant the compliance windows
188
+ * compare against.
189
+ */
190
+ async function readCommitRecords(cwd) {
191
+ const { safeExec } = await import('@mmnto/totem');
192
+ try {
193
+ const out = safeExec('git', ['log', `--max-count=${MAX_COMMITS}`, '--format=%H %cI'], {
194
+ cwd,
195
+ maxBuffer: 10 * 1024 * 1024,
196
+ });
197
+ const records = [];
198
+ for (const line of out.split('\n')) {
199
+ const trimmed = line.trim();
200
+ if (trimmed.length === 0)
201
+ continue;
202
+ const sp = trimmed.indexOf(' ');
203
+ if (sp === -1)
204
+ continue;
205
+ records.push({ sha: trimmed.slice(0, sp), timestamp: trimmed.slice(sp + 1).trim() });
206
+ }
207
+ return records;
208
+ // totem-context: best-effort git read — an empty array is the documented "no history / git unavailable" surface for this sensor (Tenet 13), never a crash of the doctor pipeline.
209
+ }
210
+ catch {
211
+ return [];
212
+ }
213
+ }
214
+ /**
215
+ * CLI entry — renders the Compliance Rate readout. Pure sensor: never throws
216
+ * for a compliance verdict, never sets a non-zero exit code (Tenet 13). Absent
217
+ * log file → the doctor `skip` idiom pointing at the MCP wiring, NOT a fail and
218
+ * NOT 0%.
219
+ */
220
+ export async function doctorComplianceCliCommand(options = {}) {
221
+ const fs = await import('node:fs');
222
+ const path = await import('node:path');
223
+ const { sanitizeForTerminal } = await import('@mmnto/totem');
224
+ const { bold, log } = await import('../ui.js');
225
+ const cwd = options.cwdForTest ?? process.cwd();
226
+ // Resolve the totemDir from config best-effort; default to `.totem` (mirrors
227
+ // the other doctor checks, which never hard-fail on a config-less repo).
228
+ let totemDir = '.totem';
229
+ try {
230
+ const { loadConfig, resolveConfigPath } = await import('../utils.js');
231
+ const config = await loadConfig(resolveConfigPath(cwd));
232
+ totemDir = config.totemDir;
233
+ // totem-context: a missing/corrupt config is the honest-absent path (default `.totem`), not a sensor failure — the doctor runs against config-less repos by design.
234
+ }
235
+ catch (err) {
236
+ if (err instanceof Error && err.message.length === 0)
237
+ throw err;
238
+ }
239
+ const logPath = path.join(cwd, totemDir, '.search-log.jsonl');
240
+ // totemDir is repo-controlled config — sanitize before it reaches a terminal.
241
+ const displayLogPath = sanitizeForTerminal(path.join(totemDir, '.search-log.jsonl'));
242
+ let content;
243
+ if (options.logContentForTest !== undefined) {
244
+ content = options.logContentForTest;
245
+ }
246
+ else if (!fs.existsSync(logPath)) {
247
+ // Absent log file → skip idiom (NOT a fail, NOT 0%). Point at the producer.
248
+ log.dim(TAG, `SKIP — no ${displayLogPath} found. The log is produced by the MCP search_knowledge tool; wire the MCP server (.mcp.json) and run some search_knowledge calls, then re-run totem doctor --compliance.`);
249
+ return;
250
+ }
251
+ else {
252
+ try {
253
+ content = fs.readFileSync(logPath, 'utf-8');
254
+ // totem-context: an unreadable search-log is the honest-absent path for this cosmetic sensor — degrade to the skip idiom, never crash the doctor pipeline.
255
+ }
256
+ catch {
257
+ log.dim(TAG, `SKIP — ${displayLogPath} present but unreadable.`);
258
+ return;
259
+ }
260
+ }
261
+ const { entries, malformedCount } = parseSearchLog(content);
262
+ const commits = options.commitsForTest ?? (await readCommitRecords(cwd));
263
+ const report = computeCompliance(entries, commits);
264
+ // ── Render ──
265
+ log.info(TAG, bold('Compliance Rate'));
266
+ log.info(TAG, `repo-wide: ${formatRate(report.overall)}`);
267
+ // Caveat line (ruled in on #2362) — a caveat, never a rename.
268
+ log.dim(TAG, 'commit-granularity per ADR-029 § 1');
269
+ // Coverage is a diagnostic, never a per-seat rate: commits carry no seat
270
+ // identity, so a seat-partitioned Compliance Rate would fabricate commit
271
+ // ownership (2026-07-15 panel fold; see the header).
272
+ if (report.coverage.length > 0) {
273
+ log.dim(TAG, 'search attribution coverage (diagnostic — not compliance):');
274
+ for (const { bucket, entries } of report.coverage) {
275
+ const legacyNote = bucket === UNATTRIBUTED ? ' (legacy / hookless — no agent_source)' : '';
276
+ log.dim(TAG, ` ${sanitizeForTerminal(bucket)}: ${entries} entr${entries === 1 ? 'y' : 'ies'}${legacyNote}`);
277
+ }
278
+ }
279
+ if (report.searchOnlySessions > 0) {
280
+ log.dim(TAG, `${report.searchOnlySessions} search-only session(s) excluded (searched, no commit — not a coding session)`);
281
+ }
282
+ if (malformedCount > 0) {
283
+ log.warn(TAG, `${malformedCount} malformed log line(s) skipped`);
284
+ }
285
+ }
286
+ //# sourceMappingURL=doctor-compliance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doctor-compliance.js","sourceRoot":"","sources":["../../src/commands/doctor-compliance.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=doctor-compliance.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doctor-compliance.test.d.ts","sourceRoot":"","sources":["../../src/commands/doctor-compliance.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { computeCompliance, formatRate, parseSearchLog, } from './doctor-compliance.js';
3
+ // ─── Fixture helpers (pure — no fs, no git, no temp dirs) ───
4
+ const H = 60 * 60 * 1000;
5
+ const MIN = 60 * 1000;
6
+ const BASE = Date.parse('2026-07-15T10:00:00.000Z');
7
+ const iso = (ms) => new Date(ms).toISOString();
8
+ function entry(offsetMs, agent_source = null, session_id = null) {
9
+ return { timestamp: iso(BASE + offsetMs), agent_source, session_id };
10
+ }
11
+ function commit(offsetMs, sha = `sha-${offsetMs}`) {
12
+ return { sha, timestamp: iso(BASE + offsetMs) };
13
+ }
14
+ /** Coverage entry-count for a bucket (undefined when the bucket is absent). */
15
+ function coverage(report, name) {
16
+ return report.coverage.find((c) => c.bucket === name)?.entries;
17
+ }
18
+ /** Raw overall rate for the algebraic invariants (compliant / n). */
19
+ function rate(report) {
20
+ return report.overall.n === 0 ? 0 : report.overall.compliant / report.overall.n;
21
+ }
22
+ /** Invariant: the overall stat satisfies 0 ≤ compliant ≤ n (⇒ 0 ≤ rate ≤ 1). */
23
+ function assertRateBounds(report) {
24
+ expect(report.overall.n).toBeGreaterThanOrEqual(0);
25
+ expect(report.overall.compliant).toBeGreaterThanOrEqual(0);
26
+ expect(report.overall.compliant).toBeLessThanOrEqual(report.overall.n);
27
+ }
28
+ // ─── parseSearchLog ─────────────────────────────────────
29
+ describe('parseSearchLog', () => {
30
+ it('empty log → no entries, no malformed', () => {
31
+ expect(parseSearchLog('')).toEqual({ entries: [], malformedCount: 0 });
32
+ });
33
+ it('single-entry log parses one entry', () => {
34
+ const line = JSON.stringify({ timestamp: iso(BASE), agent_source: 'claude' });
35
+ const result = parseSearchLog(line);
36
+ expect(result.entries).toHaveLength(1);
37
+ expect(result.entries[0].agent_source).toBe('claude');
38
+ expect(result.malformedCount).toBe(0);
39
+ });
40
+ it('skips a corrupt JSONL line with a warn count, still parses the rest', () => {
41
+ const good1 = JSON.stringify({ timestamp: iso(BASE), agent_source: 'claude' });
42
+ const good2 = JSON.stringify({ timestamp: iso(BASE + H), agent_source: 'gemini' });
43
+ const content = [good1, '{ this is not valid json', good2].join('\n');
44
+ const result = parseSearchLog(content);
45
+ expect(result.entries).toHaveLength(2);
46
+ expect(result.malformedCount).toBe(1);
47
+ });
48
+ it('treats a line with a missing/unparseable timestamp as malformed', () => {
49
+ const noTs = JSON.stringify({ agent_source: 'claude' });
50
+ const badTs = JSON.stringify({ timestamp: 'not-a-date', agent_source: 'claude' });
51
+ const result = parseSearchLog([noTs, badTs].join('\n'));
52
+ expect(result.entries).toHaveLength(0);
53
+ expect(result.malformedCount).toBe(2);
54
+ });
55
+ it('absent agent_source normalizes to null (→ unattributed at compute time)', () => {
56
+ const line = JSON.stringify({ timestamp: iso(BASE) });
57
+ const result = parseSearchLog(line);
58
+ expect(result.entries[0].agent_source).toBeNull();
59
+ });
60
+ });
61
+ // ─── computeCompliance — the merged-stream § 2 lock set ─
62
+ describe('computeCompliance', () => {
63
+ it('search-only window (no commits) → excluded from the rate, surfaced', () => {
64
+ const report = computeCompliance([entry(0, 'claude')], []);
65
+ expect(report.overall.n).toBe(0);
66
+ expect(report.searchOnlySessions).toBe(1);
67
+ assertRateBounds(report);
68
+ });
69
+ it('commits with no preceding search → one non-compliant window', () => {
70
+ const report = computeCompliance([], [commit(0), commit(30 * MIN)]);
71
+ // Two commits 30m apart roll into one commit-only window.
72
+ expect(report.overall).toEqual({ n: 1, compliant: 0 });
73
+ assertRateBounds(report);
74
+ });
75
+ it('search before commit in one window → compliant', () => {
76
+ const report = computeCompliance([entry(0, 'claude')], [commit(30 * MIN)]);
77
+ expect(report.overall).toEqual({ n: 1, compliant: 1 });
78
+ assertRateBounds(report);
79
+ });
80
+ it('clock skew (commit precedes the search in the same window) → non-compliant', () => {
81
+ const report = computeCompliance([entry(0, 'claude')], [commit(-1 * H)]);
82
+ expect(report.overall).toEqual({ n: 1, compliant: 0 });
83
+ assertRateBounds(report);
84
+ });
85
+ it('equal search/commit timestamps do NOT credit ("preceded" per § 3 is strict)', () => {
86
+ const report = computeCompliance([entry(0, 'claude')], [commit(0)]);
87
+ expect(report.overall).toEqual({ n: 1, compliant: 0 });
88
+ assertRateBounds(report);
89
+ });
90
+ it('§ 2 merged stream: an intervening commit EXTENDS the session', () => {
91
+ // search t0 · commit +1.5h · commit +3h — each gap ≤ 2h through the merged
92
+ // stream, so this is ONE session and the t0 search covers it (compliant).
93
+ // Under search-only clustering with window-attach this would split; this
94
+ // test locks the merged § 2 semantics (2026-07-15 panel fold).
95
+ const report = computeCompliance([entry(0, 'claude')], [commit(90 * MIN), commit(180 * MIN)]);
96
+ expect(report.overall).toEqual({ n: 1, compliant: 1 });
97
+ expect(report.searchOnlySessions).toBe(0);
98
+ assertRateBounds(report);
99
+ });
100
+ it('the same events WITHOUT the bridging commit split at the 2h gap', () => {
101
+ // search t0 · commit +3h — the 3h gap splits the stream: a search-only
102
+ // window plus a non-compliant commit-only window.
103
+ const report = computeCompliance([entry(0, 'claude')], [commit(180 * MIN)]);
104
+ expect(report.overall).toEqual({ n: 1, compliant: 0 });
105
+ expect(report.searchOnlySessions).toBe(1);
106
+ assertRateBounds(report);
107
+ });
108
+ it('UTC/ISO parsing discipline — offset and Z timestamps compare on the same instant', () => {
109
+ const offsetSearch = {
110
+ timestamp: '2026-07-15T12:00:00+02:00', // = 10:00Z
111
+ agent_source: 'claude',
112
+ session_id: null,
113
+ };
114
+ const zCommit = { sha: 'z', timestamp: '2026-07-15T10:30:00Z' };
115
+ const report = computeCompliance([offsetSearch], [zCommit]);
116
+ expect(report.overall).toEqual({ n: 1, compliant: 1 });
117
+ assertRateBounds(report);
118
+ });
119
+ it('empty log + no commits → nothing counted', () => {
120
+ const report = computeCompliance([], []);
121
+ expect(report.overall).toEqual({ n: 0, compliant: 0 });
122
+ expect(report.coverage).toEqual([]);
123
+ assertRateBounds(report);
124
+ });
125
+ it('attribution does NOT change the repo-wide rate (identical timelines, different seats)', () => {
126
+ // Commits carry no seat identity, so seat labels must be rate-inert: the
127
+ // same instants produce the same overall stat whether entries are
128
+ // attributed, mixed, or all unattributed.
129
+ const times = [
130
+ [0, 'claude'],
131
+ [10 * MIN, 'gemini'],
132
+ [15 * MIN, 'claude'],
133
+ [130 * MIN, null],
134
+ ];
135
+ const commits = [commit(30 * MIN), commit(180 * MIN)];
136
+ const attributed = computeCompliance(times.map(([ms, seat]) => entry(ms, seat)), commits);
137
+ const unattributed = computeCompliance(times.map(([ms]) => entry(ms, null)), commits);
138
+ expect(attributed.overall).toEqual(unattributed.overall);
139
+ expect(attributed.searchOnlySessions).toBe(unattributed.searchOnlySessions);
140
+ assertRateBounds(attributed);
141
+ });
142
+ it('coverage counts entries per seat; null lands in the unattributed bucket', () => {
143
+ const report = computeCompliance([entry(0, 'totem-claude'), entry(5 * MIN, 'totem-claude'), entry(10 * MIN, null)], [commit(30 * MIN)]);
144
+ expect(coverage(report, 'totem-claude')).toBe(2);
145
+ expect(coverage(report, 'unattributed')).toBe(1);
146
+ expect(report.unattributedEntries).toBe(1);
147
+ // Sorted by bucket name.
148
+ expect(report.coverage.map((c) => c.bucket)).toEqual(['totem-claude', 'unattributed']);
149
+ });
150
+ it('session_id is deliberately inert in the windowing (no commit-side join exists)', () => {
151
+ // Two searches sharing a session_id 5h apart do NOT bridge the 2h gap:
152
+ // the stamp is a forward primitive for the commit-side join, not a
153
+ // windowing input in the minimal slice (see the module header).
154
+ const report = computeCompliance([entry(0, 'claude', 'sid-1'), entry(5 * H, 'claude', 'sid-1')], [commit(30 * MIN)]);
155
+ expect(report.overall).toEqual({ n: 1, compliant: 1 }); // first window: search+commit
156
+ expect(report.searchOnlySessions).toBe(1); // the 5h-later search stands alone
157
+ assertRateBounds(report);
158
+ });
159
+ });
160
+ // ─── Algebraic invariants ───────────────────────────────
161
+ describe('algebraic invariants', () => {
162
+ it('rate always sits in [0, 1] across a mixed fixture', () => {
163
+ const entries = [
164
+ entry(0, 'claude'),
165
+ entry(5 * H, 'claude'),
166
+ entry(0, 'gemini'),
167
+ ];
168
+ const commits = [commit(30 * MIN), commit(4 * H), commit(10 * MIN)];
169
+ assertRateBounds(computeCompliance(entries, commits));
170
+ });
171
+ it('adding a compliant session never lowers the rate', () => {
172
+ // Base: one compliant window + one non-compliant commit-only window.
173
+ const baseEntries = [entry(0, 'claude')];
174
+ const baseCommits = [commit(30 * MIN), commit(6 * H)];
175
+ const before = computeCompliance(baseEntries, baseCommits);
176
+ expect(rate(before)).toBeCloseTo(0.5, 5);
177
+ // Add one clearly-compliant far-future window (search then commit).
178
+ const afterEntries = [...baseEntries, entry(20 * H, 'claude')];
179
+ const afterCommits = [...baseCommits, commit(20 * H + 30 * MIN)];
180
+ const after = computeCompliance(afterEntries, afterCommits);
181
+ expect(rate(after)).toBeGreaterThanOrEqual(rate(before));
182
+ assertRateBounds(after);
183
+ });
184
+ });
185
+ // ─── formatRate ─────────────────────────────────────────
186
+ describe('formatRate', () => {
187
+ it('renders "insufficient data (n=x)" below the sample floor', () => {
188
+ expect(formatRate({ n: 0, compliant: 0 })).toBe('insufficient data (n=0)');
189
+ expect(formatRate({ n: 4, compliant: 4 })).toBe('insufficient data (n=4)');
190
+ });
191
+ it('renders a percentage at or above the sample floor (name stays "Compliance Rate")', () => {
192
+ expect(formatRate({ n: 5, compliant: 4 })).toBe('80% (n=5)');
193
+ expect(formatRate({ n: 10, compliant: 10 })).toBe('100% (n=10)');
194
+ expect(formatRate({ n: 8, compliant: 0 })).toBe('0% (n=8)');
195
+ });
196
+ });
197
+ //# sourceMappingURL=doctor-compliance.test.js.map