trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
//! `tm issue …` — YAML-configurable issue state-management verbs (#1246).
//!
//! Why: externalizes the Unicorn Factory's hardcoded issue state machine (label
//! set, allowed transitions, assignee model) into a YAML contract owned by
//! trusty-mpm, surfaced as `tm issue` verbs the Python harness consumes by
//! shelling out. Every operation maps to a concrete label/assignee/comment
//! mutation, so issue state stays reconstructable from GitHub artifacts alone.
//! What: the [`issue`] dispatcher that selects the `gh` backend, resolves the
//! `agents.ticketing` standard (#6918), loads + validates the model (config
//! discovery: flag > the repo's own model, found by walking the cwd up to the
//! git toplevel (#7580) > `agents.ticketing.lifecycle_model` > user > embedded
//! default — and since #7580 the model's SOURCE rides with it, so a verb's
//! output and its failures name which of those answered), and runs the
//! requested verb (`seed-labels`, `transition`,
//! `current`, `states`, `standard`, `seed-config`, `repair`, `audit`). Schema
//! types live in `config.rs`, validation in `validate.rs`, the state machine in
//! `state.rs`, the operations in `ops.rs`, the `standard` printer in
//! `standard.rs`, the #7097 `audit` verb in `audit.rs`.
//! Test: pure logic is unit-tested in the submodules (`config`/`validate`/
//! `state`/`ops`); CLI parsing in `tests.rs`.

pub(crate) mod audit;
pub(crate) mod config;
pub(crate) mod epic;
pub(crate) mod ops;
pub(crate) mod seed_ticketing;
pub(crate) mod standard;
pub(crate) mod standard_components;
pub(crate) mod standard_live;
pub(crate) mod state;
pub(crate) mod validate;

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

use std::path::PathBuf;

use crate::cli::IssueCmd;
use crate::commands::ticket::runner::{CommandRunner, RealCommandRunner};
use crate::commands::ticket::system::{
    GhTicketSystem, TicketSystem, TicketSystemKind, not_yet_supported,
};

use config::{
    ModelSource, StateModel, builtin_for_target, describe_source, load_model_with_source,
};
use seed_ticketing::{
    TicketingSeedOutcome, seed_outcome_result, seed_ticketing_block, ticketing_config_path,
};
use trusty_mpm::core::trusty_tools_config::{
    TICKETING_BLOCK_TEMPLATE, TrustyToolsConfig, resolve_ticketing,
};

/// `tm issue <subcommand>` dispatcher.
///
/// Why: the single operator/harness entry point for the state-management verbs.
/// What: builds the `gh`-backed [`TicketSystem`] (rejecting non-`gh` backends
/// with the shared stub error), then dispatches each [`IssueCmd`] variant —
/// loading + validating the model first for the verbs that need it.
/// Test: parsing in `tests.rs`; per-verb logic in the submodule unit tests.
pub(crate) fn issue(cmd: IssueCmd, system: TicketSystemKind) -> anyhow::Result<()> {
    // #1265: bind the active project's GitHub identity to every `gh` call this
    // verb makes (empty binding → ambient gh identity, no regression).
    let gh_env = crate::gh_identity::load_gh_env()?;
    // #7067: `standard`'s live milestone/project read-back needs a runner of
    // its own — the backend owns one but does not expose it. Same binding, so
    // both halves of the verb speak to GitHub as the same identity.
    let runner = RealCommandRunner::with_gh_env(&gh_env);
    let backend = match system {
        TicketSystemKind::Gh => GhTicketSystem::new(RealCommandRunner::with_gh_env(&gh_env)),
        TicketSystemKind::Jira => return Err(not_yet_supported("jira")),
        TicketSystemKind::Linear => return Err(not_yet_supported("linear")),
    };
    dispatch(&backend, &runner, &gh_env, cmd)
}

/// Dispatch a parsed [`IssueCmd`] against a backend (generic for testability).
///
/// Why: separating dispatch from backend construction keeps the verb wiring
/// independent of `gh`, so the orchestration could be exercised with a fake.
/// `gh_env` rides along for the one verb (#7097's `audit`) whose `gh` calls go
/// through the workspace's shared `GhCommand` entry point rather than through
/// the `CommandRunner` seam the other verbs share.
/// What: matches each verb, loads the model where required, runs the op, and
/// prints a human summary.
/// Test: per-verb ops are unit-tested; this is thin glue.
fn dispatch<S: TicketSystem>(
    backend: &S,
    runner: &dyn CommandRunner,
    // #7097: `audit` reaches `gh` through `trusty_common::gh::GhCommand` rather
    // than the `CommandRunner` seam, so it needs the resolved identity itself.
    gh_env: &trusty_mpm::core::gh_identity::GhEnv,
    cmd: IssueCmd,
) -> anyhow::Result<()> {
    // #6918: resolve the operator's ticketing standard ONCE. An absent
    // `agents.ticketing` block yields the built-in defaults; a malformed one is
    // an error here rather than a silent revert to them.
    let ticketing = resolve_ticketing(&TrustyToolsConfig::load())?;
    let lifecycle = ticketing.lifecycle_model.as_deref();
    match cmd {
        IssueCmd::SeedLabels {
            config,
            dry_run,
            only,
        } => {
            let (model, source) = load_model_with_source(config.as_deref(), lifecycle)?;
            // #6914: the `ws/<session>` policy label needs the same session
            // name session launch labels with — the tmux session name.
            let session = crate::commands::tmux_attach::current_tmux_session_name();
            let report = ops::seed_labels(
                backend,
                &model,
                &ticketing,
                session.as_deref(),
                dry_run,
                &only,
            )
            .map_err(|e| with_source(e, &source))?;
            // #7983: a scoped run says so, so a short created-list is never read
            // as "the model declares nothing else".
            if !only.is_empty() {
                println!("scoped to --only [{}]", only.join(", "));
            }
            print_seed_report(&report);
        }
        IssueCmd::Standard { config } => {
            let (model, _source) = load_model_with_source(config.as_deref(), lifecycle)?;
            // #7837: the working directory `gh` runs in is also the workspace
            // whose crates are this repository's components.
            standard::print_standard(
                &ticketing,
                &model,
                runner,
                std::env::current_dir().ok().as_deref(),
            );
        }
        IssueCmd::Transition {
            issue,
            to_state,
            config,
            note,
        } => {
            // #7580: a rejected state name is the error this bug reads as, so
            // the model in force is named beside it.
            // #8609: with no model file, a `status:*` target selects the
            // built-in `status:*` lifecycle rather than failing the default.
            let (model, source) = builtin_for_target(
                load_model_with_source(config.as_deref(), lifecycle)?,
                &to_state,
            )?;
            // #8448: a phase issue's transition regenerates its tracker's
            // phases block, and a failed regeneration is THIS command's
            // failure — the hook composes the two so it cannot be swallowed.
            let epic_backend =
                epic::backend::GhEpicBackend::new(RealCommandRunner::with_gh_env(gh_env));
            let hooked = epic::hook::transition_with_tracker_sync(
                backend,
                &epic_backend,
                &model,
                issue,
                &to_state,
                note.as_deref(),
            )
            .map_err(|e| with_source(e, &source))?;
            let report = hooked.report;
            // #8003: a no-op is stdout-silent and says so on stderr, so a
            // script piping stdout still reads only real transitions while the
            // operator is told why nothing moved.
            if report.no_op {
                eprintln!(
                    "#{issue} is already `{}` — no change made (no label, assignee or comment \
                     was written)",
                    report.to
                );
                // #8448: a no-op on a phase still synced its tracker (the
                // re-run after an interrupted sync IS a no-op); say what that
                // did, on stderr so stdout stays silent for scripts.
                if let Some(synced) = hooked.synced {
                    eprintln!("{}", tracker_sync_line(&synced));
                }
                return Ok(());
            }
            let from = report.from.as_deref().unwrap_or("(none)");
            println!("transitioned #{issue}: {from} → {}", report.to);
            if report.assignee_changed {
                println!("  assignee rule applied");
            }
            if let Some(synced) = hooked.synced {
                println!("{}", tracker_sync_line(&synced));
            }
        }
        IssueCmd::Current { issue, config } => {
            let (model, source) = load_model_with_source(config.as_deref(), lifecycle)?;
            let state =
                ops::current(backend, &model, issue).map_err(|e| with_source(e, &source))?;
            println!("{state}");
        }
        IssueCmd::States { config } => {
            // #7580: `tm issue states` is what an operator reads to learn the
            // valid names, so it says which model produced them.
            let (model, source) = load_model_with_source(config.as_deref(), lifecycle)?;
            print_states(&model, &source);
        }
        IssueCmd::SeedConfig { force } => {
            seed_config(force)?;
        }
        // #7097: reads only — no `gh` write, and no state-model load for a
        // window. #8448: only the single-issue path loads the model, for the
        // epic rows' status prefix.
        IssueCmd::Audit {
            issue,
            recent,
            since,
        } => {
            audit::run(&ticketing, gh_env, runner, issue, recent, since, lifecycle)?;
        }
        // #8447: the epic verbs reach `gh` and `git` through their own narrow
        // `EpicBackend` seam (D7), so they need the identity; #8448: `create`
        // and `sync` also read the model's status prefix.
        IssueCmd::Epic(epic_cmd) => {
            epic::run(epic_cmd, gh_env, lifecycle)?;
        }
        IssueCmd::Repair { issue, config } => {
            let (model, source) = load_model_with_source(config.as_deref(), lifecycle)?;
            let kept = ops::repair(backend, &model, issue).map_err(|e| with_source(e, &source))?;
            println!("repaired #{issue}: resolved to `{kept}`");
        }
    }
    Ok(())
}

/// Print a `seed-labels` summary.
///
/// Why: operators need to see what was created vs. already present.
/// What: prints the created and already-present label lists, flagging dry-runs.
/// Test: side-effect-only (stdout); the report itself is unit-tested in `ops`.
fn print_seed_report(report: &ops::SeedReport) {
    if report.dry_run {
        println!("[dry-run] would create {} label(s):", report.created.len());
    } else {
        println!("created {} label(s):", report.created.len());
    }
    for name in &report.created {
        println!("  + {name}");
    }
    println!("already present: {} label(s)", report.already_present.len());
    // #6914: an omitted ws/ label is stated, never silent.
    if report.workstream_skipped {
        println!("skipped ws/<session>: no tmux session name to derive it from");
    }
}

/// Append the model's source to a verb's failure (#7580).
///
/// Why: "unknown target state `status:merged`; valid states: [queued, …]" reads
/// as a mistyped state name. The one fact that turns it into the actual
/// diagnosis — which model produced that list — is the source, so it rides on
/// every verb failure rather than being something the operator has to go and ask
/// for.
/// What: reformats the error with [`describe_source`] on its own line. The
/// original message is kept verbatim and first.
/// Test: `verb_failure_names_the_embedded_default_source_7580`.
fn with_source(err: anyhow::Error, source: &ModelSource) -> anyhow::Error {
    anyhow::anyhow!("{err:#}\n  {}", describe_source(source))
}

/// The one line a transition prints about its tracker sync (#8448).
///
/// Test: side-effect-only; the report itself is asserted by the
/// `transition_hook_*` tests.
fn tracker_sync_line(synced: &epic::sync::SyncReport) -> String {
    if synced.unchanged {
        format!(
            "  tracker #{}: phases block already current",
            synced.tracker
        )
    } else {
        format!("  tracker #{}: phases block regenerated", synced.tracker)
    }
}

/// Print the configured states and transitions.
///
/// Why: operator introspection of the active model (reads YAML only, no `gh`).
/// What: lists each state (its label, or `(no label)` for a label-less state,
/// plus the terminal flag) then each transition edge, marking the edges that
/// require a `--note`. Since #7580 the model's own source is printed first, so a
/// list of built-in states is never mistaken for the project's.
/// Test: side-effect-only (stdout); the model is unit-tested in `config`.
fn print_states(model: &StateModel, source: &ModelSource) {
    println!("{}", describe_source(source));
    println!("states ({}):", model.states.len());
    for s in &model.states {
        let term = if s.terminal { " [terminal]" } else { "" };
        let label = s.label.as_ref().map_or("(no label)", |l| l.name.as_str());
        println!("  {} → {}{}", s.name, label, term);
    }
    println!("transitions ({}):", model.transitions.len());
    for t in &model.transitions {
        let from = t.from.as_deref().unwrap_or("null");
        let note = if t.requires_note {
            " [--note required]"
        } else {
            ""
        };
        println!("  {from} → {} ({:?}){note}", t.to, t.trigger);
    }
}

/// `tm issue seed-config [--force]` — write both halves of the standard.
///
/// Why: lets operators start from a copy of the default model and edit it,
/// mirroring `tm services init` (RFC §6).
/// What: writes [`config::DEFAULT_MODEL_YAML`] to
/// `~/.trusty-tools/trusty-mpm/issue-state.yaml`, creating parent dirs; refuses
/// to overwrite an existing file unless `--force`. Then seeds the
/// [`TICKETING_BLOCK_TEMPLATE`] into `config.yaml` — the file
/// [`TrustyToolsConfig::load`] reads — via
/// [`seed_ticketing_block`] (#7067), so the milestone and project half of the
/// standard lands on disk rather than being printed for the operator to paste.
/// That half is never overwritten: an existing `agents.ticketing` is left as
/// the operator wrote it. One outcome exits NONZERO — an `agents:` key with no
/// `ticketing:` cannot be appended to, so the block is printed for a manual
/// paste and the command fails rather than reporting a seed it did not do.
/// Test: side-effect-only (filesystem/stdout); the write itself is covered by
/// the `seed_ticketing` tests, the exit split by
/// `only_the_refusal_outcome_exits_nonzero`, the template by
/// `the_seed_template_parses_to_the_builtin_defaults`.
fn seed_config(force: bool) -> anyhow::Result<()> {
    let path: PathBuf = config::user_config_path()
        .ok_or_else(|| anyhow::anyhow!("could not resolve home directory for the user config"))?;
    if path.exists() && !force {
        anyhow::bail!(
            "config already exists at {} — pass --force to overwrite",
            path.display()
        );
    }
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| {
            anyhow::anyhow!("failed to create config dir {}: {e}", parent.display())
        })?;
    }
    std::fs::write(&path, config::DEFAULT_MODEL_YAML)
        .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?;
    println!("wrote default issue-state model to {}", path.display());

    // #7067: the milestone/project keys live in a DIFFERENT file, so write
    // them there too rather than leaving the operator to paste a block.
    let config_yaml = ticketing_config_path()
        .ok_or_else(|| anyhow::anyhow!("could not resolve home directory for the user config"))?;
    let shown = config_yaml.display();
    let outcome = seed_ticketing_block(&config_yaml)?;
    match outcome {
        TicketingSeedOutcome::Created => {
            println!("created {shown} with the agents.ticketing block");
        }
        TicketingSeedOutcome::Appended => {
            println!("appended the agents.ticketing block to {shown}");
        }
        TicketingSeedOutcome::AlreadyPresent => {
            println!("{shown} already declares agents.ticketing — left unchanged");
        }
        TicketingSeedOutcome::AgentsBlockPresent => {
            println!(
                "{shown} declares `agents:` without `ticketing:` — left unchanged, \
                 since a second `agents:` key would make the file unreadable. \
                 Add this entry under the `agents:` block by hand:\n"
            );
            print!("{TICKETING_BLOCK_TEMPLATE}");
        }
    }
    // #7067: the refusal arm exits nonzero — the standard is half-applied and a
    // script must not read that as a completed seed.
    seed_outcome_result(outcome, &config_yaml)
}

#[cfg(test)]
mod source_annotation_tests {
    use super::*;

    // #7580: a verb failure carries the model's source, so an unknown-state
    // error cannot be read as a typo when the built-in model is in force.
    #[test]
    fn verb_failure_names_the_embedded_default_source_7580() {
        let source = ModelSource::EmbeddedDefault {
            searched_from: PathBuf::from("/repo/crates/trusty-mpm"),
        };
        let err = with_source(
            anyhow::anyhow!("unknown target state `status:merged`; valid states: [queued]"),
            &source,
        );
        let text = format!("{err}");
        assert!(text.contains("unknown target state"), "{text}");
        assert!(text.contains("BUILT-IN default"), "{text}");
    }

    #[test]
    fn verb_failure_names_a_file_source() {
        let source = ModelSource::File(PathBuf::from("/repo/issue-state.yaml"));
        let text = format!("{}", with_source(anyhow::anyhow!("boom"), &source));
        assert!(
            text.contains("state model: /repo/issue-state.yaml"),
            "{text}"
        );
    }
}