trusty-mpm 1.3.5

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Regex/NLP parse layer for session output (§8.2 of SPEC-SESSCTL-01, WI-3 #1594).
//!
//! Why: raw session output bytes must be classified into structured
//! `ActivityKind` labels WITHOUT any LLM call so the daemon can update
//! `SessionMetadata.last_summary` and emit `ActivityParsed`/`PendingDecision`
//! events with zero network latency.
//! What: [`ActivityParser`] owns compiled `Regex` patterns (via `OnceLock`)
//! and exposes `parse_output`, which returns an optional [`ParseResult`]
//! carrying the matched kind, a short human summary, and optional
//! pending-decision fields.
//! Test: `activity_parser_*` table-driven tests in the inline test module.

use std::sync::OnceLock;

use regex::Regex;

use crate::control::event::ActivityKind;

// ── Compiled regex statics ────────────────────────────────────────────────────

/// Returns the regex for Claude's interactive `> ` prompt.
///
/// Why: the prompt is the primary signal that Claude is awaiting user input.
/// What: matches lines starting with `> ` or the standalone pattern.
/// Test: `activity_parser_awaiting_input`.
fn re_awaiting_input() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"(?m)^\s*>\s").expect("awaiting_input regex"))
}

/// Returns the regex for tool-use lines.
///
/// Why: observers want to know when Claude is invoking a tool.
/// What: matches `Tool use: <name>` or `Using tool: <name>`.
/// Test: `activity_parser_tool_use`.
fn re_tool_use() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r"(?i)(?:Tool use|Using tool):\s*(?P<name>\S+)").expect("tool_use regex")
    })
}

/// Returns the regex for error lines.
///
/// Why: surface error conditions to observers without waiting for an LLM pass.
/// What: matches lines starting with `Error:` or containing ANSI error escape.
/// Test: `activity_parser_error`.
fn re_error() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r"(?m)(?:(?:^|\n)\s*Error:\s*(?P<msg>[^\n]{1,120})|error\[E\d+\])")
            .expect("error regex")
    })
}

/// Returns the regex for cargo/pytest test result lines.
///
/// Why: test results are high-signal events for the SM agent and operators.
/// What: matches `test result: ok. N passed; M failed` or
/// `M tests passed` / `M passed, N failed`.
/// Test: `activity_parser_test_result`.
fn re_test_result() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(
            r"(?xi)
            # cargo test: `test result: ok. N passed; M failed`
            test\ result:\ (?:ok|FAILED)\.\ \s*
            (?P<p1>\d+)\ passed;\ (?P<f1>\d+)\ failed
            |
            # pytest style: `N passed` or `N passed, M failed`
            (?P<p2>\d+)\ passed(?:,\s*(?P<f2>\d+)\ failed)?
            |
            # also match `N tests passed`
            (?P<p3>\d+)\ tests?\ passed
            ",
        )
        .expect("test_result regex")
    })
}

/// Returns the regex for git operation confirmations.
///
/// Why: git commit/push are high-value signals for the SM agent. Word-boundary
/// anchors (`\b`) and an optional `git ` prefix prevent false matches on prose
/// phrases like "fetching dependencies" or "merging results from database".
/// What: matches `[branch abc] commit message` lines (git commit output) OR
/// git verb lines that start with `git ` / appear at the line start with a
/// \b boundary so only standalone git vocabulary triggers a match.
/// Test: `activity_parser_git_op`, `activity_parser_git_op_no_false_positives`.
fn re_git_op() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(
            r"(?xi)
            (?:
                # git commit output: [branch abc1234] message
                # Require a real short-SHA length (7–40 hex chars) so that
                # short identifiers like `[session a1b2c3]` (6 chars) or
                # `[error 404abc]` (6 chars) do NOT match.
                \[(?P<branch>\S+)\s+[0-9a-f]{7,40}\]\s+(?P<msg>.{1,80})
                |
                # explicit `git <verb>` invocation
                \bgit\s+(?P<verb>push|pull|merge|fetch|commit|rebase|clone)(?:ing|ed)?\b
            )",
        )
        .expect("git_op regex")
    })
}

/// Returns the regex for OAuth / Max login prompts.
///
/// Why: §4.4 and §8.2 require `AuthPrompt` detection for both backends.
/// What: matches known Claude auth/login prompt phrases.
/// Test: `activity_parser_auth_prompt`.
fn re_auth_prompt() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(
            r"(?i)(?:please\s+log\s*in|claude\.ai.*login|oauth|log\s*in\s+to\s+claude|authentication\s+required|run\s+claude\s+auth\s+login)",
        )
        .expect("auth_prompt regex")
    })
}

/// Returns the regex for session-complete signals.
///
/// Why: clean exit from a session should be surfaced to observers immediately.
/// What: matches `Session complete` or `session complete` phrases.
/// Test: `activity_parser_session_complete`.
fn re_session_complete() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"(?i)session\s+complete").expect("session_complete regex"))
}

/// Returns the regex for rate-limit errors (§9.2).
///
/// Why: rate-limit events must be surfaced so the SM agent can back off.
/// What: matches known rate-limit error phrases from the Claude API.
/// Test: `activity_parser_rate_limited`.
fn re_rate_limited() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r"(?i)(?:rate.?limit|too\s+many\s+requests|429)").expect("rate_limited regex")
    })
}

/// Returns the regex for decision prompts with an optional `[Y/n]` default.
///
/// Why: §8.4 — when the output contains a `[Y/n]`-style prompt the daemon
/// must extract both the prompt text and the proposed default.
/// What: matches `… [Y/n]`, `… (yes/no)`, `… (Yes/no)`, `… (yes/No)`, or
/// generic `?` question endings. Named captures:
///   `yes` / `no` — the letter inside `[Y/n]` brackets; uppercase letter = default.
///   `pyes` / `pno` — the word inside `(yes/no)` parens; uppercase first letter = default.
/// §8.4 convention: the uppercase option is the proposed default. For the
/// `(yes/no)` form where neither option is capitalised, `proposed_default` is
/// `None` (no default specified by the prompt).
/// Test: `activity_parser_pending_decision`, `activity_parser_yes_no_proposed_default`.
fn re_pending_decision() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(
            r"(?x)
            (?P<question>[^\n]{10,200}?)              # question text (non-greedy)
            \s*
            (?:
                \[(?P<yes>[Yy])/(?P<no>[Nn])\]        # [Y/n] or [y/N]
                |
                \((?P<pyes>(?:yes|no|Yes|No))          # (Yes/no) / (yes/No) / (yes/no)
                  /(?P<pno>(?:yes|no|Yes|No))\)
            )
            \s*:?\s*$                                  # optional colon + line end
            ",
        )
        .expect("pending_decision regex")
    })
}

// ── Result type ───────────────────────────────────────────────────────────────

/// Output of a successful `ActivityParser::parse_output` call.
///
/// Why: callers (the actor loop) need all three fields to update metadata and
/// emit the right events without re-running the parse.
/// What: carries the classified kind, a human-readable summary, and optional
/// pending-decision fields extracted from the output.
/// Test: `activity_parser_*` table-driven tests.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseResult {
    /// The classified activity kind.
    pub kind: ActivityKind,
    /// Short human-readable summary (<= 200 chars).
    pub summary: String,
    /// If the output is asking the user a question, the prompt text.
    pub pending_decision: Option<String>,
    /// If a `[Y/n]`-style default is present, the proposed answer.
    pub proposed_default: Option<String>,
}

// ── Parser ────────────────────────────────────────────────────────────────────

/// Zero-state regex/NLP parser for raw session output (§8.2 of SPEC-SESSCTL-01).
///
/// Why: having a struct (even with no fields) gives us a clear ownership
/// boundary and a natural extension point for configuration (e.g. custom
/// patterns per session type).
/// What: `parse_output` runs the pattern chain over `text` in priority order
/// and returns `Some(ParseResult)` on the first match, `None` when the text
/// does not match any known pattern.
/// Test: `activity_parser_returns_none_for_unknown_output`.
pub struct ActivityParser;

impl ActivityParser {
    /// Run the regex parse chain over `text`.
    ///
    /// Why: the actor calls this after every `Output` event; returning `None`
    /// on no-match avoids creating a spurious `ActivityParsed` event.
    /// What: tries each pattern in priority order (auth → rate-limit → tool-use →
    /// error → test → git → session-complete → decision → awaiting-input) and
    /// returns on the first match. Allocation only occurs when a pattern matches.
    /// Test: `activity_parser_*` table-driven tests.
    pub fn parse_output(text: &str) -> Option<ParseResult> {
        // 1. Auth prompt — highest priority; terminal risk.
        // AuthPrompt is not a user-answerable decision — the actor handles it
        // via a dedicated state transition (AuthFailed / AwaitingAuth). Setting
        // pending_decision here would be semantically wrong and confuse the
        // actor's PendingDecision emission path, so both fields are None.
        if re_auth_prompt().is_match(text) {
            return Some(ParseResult {
                kind: ActivityKind::AuthPrompt,
                summary: "authentication / login prompt detected".into(),
                pending_decision: None,
                proposed_default: None,
            });
        }

        // 2. Rate-limit errors (§9.2).
        if re_rate_limited().is_match(text) {
            return Some(ParseResult {
                kind: ActivityKind::RateLimited,
                summary: "rate-limit error detected".into(),
                pending_decision: None,
                proposed_default: None,
            });
        }

        // 3. Session complete.
        if re_session_complete().is_match(text) {
            return Some(ParseResult {
                kind: ActivityKind::SessionComplete,
                summary: "session completed normally".into(),
                pending_decision: None,
                proposed_default: None,
            });
        }

        // 4. Tool use.
        if let Some(caps) = re_tool_use().captures(text) {
            let name = caps
                .name("name")
                .map_or("unknown", |m| m.as_str())
                .to_owned();
            let summary = format!("tool use: {name}");
            return Some(ParseResult {
                kind: ActivityKind::ToolUse { name },
                summary,
                pending_decision: None,
                proposed_default: None,
            });
        }

        // 5. Error.
        if let Some(caps) = re_error().captures(text) {
            let excerpt = caps
                .name("msg")
                .map_or_else(
                    || text.lines().next().unwrap_or("error").trim(),
                    |m| m.as_str(),
                )
                .chars()
                .take(100)
                .collect::<String>();
            let summary = format!("error: {excerpt}");
            return Some(ParseResult {
                kind: ActivityKind::Error { excerpt },
                summary,
                pending_decision: None,
                proposed_default: None,
            });
        }

        // 6. Test result.
        if let Some(caps) = re_test_result().captures(text) {
            let passed = caps
                .name("p1")
                .or_else(|| caps.name("p2"))
                .or_else(|| caps.name("p3"))
                .and_then(|m| m.as_str().parse::<u32>().ok())
                .unwrap_or(0);
            let failed = caps
                .name("f1")
                .or_else(|| caps.name("f2"))
                .and_then(|m| m.as_str().parse::<u32>().ok())
                .unwrap_or(0);
            let summary = format!("tests: {passed} passed, {failed} failed");
            return Some(ParseResult {
                kind: ActivityKind::TestResult { passed, failed },
                summary,
                pending_decision: None,
                proposed_default: None,
            });
        }

        // 7. Git operation.
        if let Some(caps) = re_git_op().captures(text) {
            // `verb` capture is set by the `git <verb>` branch (push/pull/…).
            // The bracket branch (`[branch sha] msg`) has no `verb` capture —
            // it always represents a commit, optionally annotated with the branch
            // name. We must NOT use the `branch` capture as the verb because
            // that would produce `git: main` instead of `git: commit (main)`.
            let verb = caps
                .name("verb")
                .map(|m| m.as_str().to_owned())
                .unwrap_or_else(|| {
                    let branch = caps.name("branch").map_or("", |m| m.as_str());
                    if branch.is_empty() {
                        "commit".to_owned()
                    } else {
                        format!("commit ({branch})")
                    }
                });
            let summary = format!("git: {verb}");
            return Some(ParseResult {
                kind: ActivityKind::GitOp { verb },
                summary,
                pending_decision: None,
                proposed_default: None,
            });
        }

        // 8. Pending decision prompt (§8.4) — before AwaitingInput so `[Y/n]`
        //    prompts are classified as PendingDecision, not generic AwaitingInput.
        if let Some(caps) = re_pending_decision().captures(text) {
            let question = caps
                .name("question")
                .map_or(text, |m| m.as_str())
                .trim()
                .to_owned();
            // §8.4 convention: the uppercase option is the proposed default.
            // `[Y/n]` → `yes` capture is uppercase → default "yes".
            // `[y/N]` → `yes` capture is lowercase → `no` capture is uppercase → default "no".
            // `(Yes/no)` → `pyes` starts uppercase → default "yes".
            // `(yes/No)` → `pno` starts uppercase → default "no".
            // `(yes/no)` → neither capitalised → no default (None, per §8.4).
            let proposed_default = if let Some(m) = caps.name("yes") {
                // Bracket form [Y/n] / [y/N]
                Some(
                    if m.as_str().chars().next().is_some_and(|c| c.is_uppercase()) {
                        "yes".to_owned()
                    } else {
                        "no".to_owned()
                    },
                )
            } else if let Some(pyes) = caps.name("pyes") {
                // Parenthesised form (yes/no) / (Yes/no) / (yes/No)
                let pno_upper = caps
                    .name("pno")
                    .is_some_and(|m| m.as_str().chars().next().is_some_and(|c| c.is_uppercase()));
                let pyes_upper = pyes
                    .as_str()
                    .chars()
                    .next()
                    .is_some_and(|c| c.is_uppercase());
                if pyes_upper {
                    Some("yes".to_owned())
                } else if pno_upper {
                    Some("no".to_owned())
                } else {
                    // (yes/no) — neither capitalised; no default defined.
                    None
                }
            } else {
                None
            };
            let summary = format!("awaiting decision: {question}");
            return Some(ParseResult {
                kind: ActivityKind::AwaitingInput,
                summary,
                pending_decision: Some(question),
                proposed_default,
            });
        }

        // 9. Generic awaiting-input (Claude `> ` prompt).
        if re_awaiting_input().is_match(text) {
            return Some(ParseResult {
                kind: ActivityKind::AwaitingInput,
                summary: "awaiting input".into(),
                pending_decision: None,
                proposed_default: None,
            });
        }

        None
    }
}

#[cfg(test)]
#[path = "activity_tests.rs"]
mod tests;