Skip to main content

trusty_memory/
attribution.rs

1//! Drawer creator-attribution tag helpers.
2//!
3//! Why: prior to this module, drawers carried content, room, importance,
4//! and free-form tags but no first-class metadata describing the writer.
5//! Operators who saw noise drawers in a palace had no way to trace which
6//! client wrote them — was it the trusty-memory MCP, a curl from a shell
7//! script, claude-mpm's Python hook, the dashboard form? This module
8//! defines a reserved `creator:*` tag namespace that every write path
9//! (HTTP, MCP, CLI, hook) attaches automatically. With `creator:client=…`
10//! present on every drawer, "where did this come from?" becomes
11//! grep-able. The namespace approach (vs. a `Drawer` schema change)
12//! piggy-backs on the existing `msg:` tag pattern from #99 so no
13//! migration is required.
14//!
15//! What:
16//!   - `CREATOR_*_PREFIX` constants — the four reserved tag prefixes.
17//!   - [`CreatorInfo`] — small value type carrying client name, version,
18//!     source, and optional cwd. `into_tags()` renders the four tag
19//!     strings (or three, when cwd is absent) in a stable order.
20//!   - [`is_creator_tag`] — predicate used by UI render code that wants
21//!     to hide the namespace from the main tag chips (mirroring how
22//!     `msg:*` is filtered today).
23//!
24//! Test: see the `tests` module at the bottom — covers tag composition,
25//! prefix detection, and round-trip via `is_creator_tag`.
26//!
27//! # Spec References
28//!
29//! - [`SPEC-WSCLAIM-03~draft`](docs/specs/DOC-53-workstream-claim-drawer-convention.md#SPEC-WSCLAIM-03~draft) (§4 workstream-attributed memory)
30
31use crate::ActivitySource;
32
33/// Tag prefix carrying the writing client's short name
34/// (e.g. `creator:client=trusty-memory-mcp`).
35///
36/// Why: the dominant question "who wrote this drawer?" reduces to a
37/// single substring search against this prefix. Stable string so curl
38/// and grep workflows keep working over time.
39/// Test: `creator_info_renders_all_fields`.
40pub const CREATOR_CLIENT_PREFIX: &str = "creator:client=";
41
42/// Tag prefix carrying the writing client's version string
43/// (e.g. `creator:version=0.5.1`).
44///
45/// Why: lets operators distinguish "old buggy client wrote this" from
46/// "current client wrote this" without rummaging through logs.
47/// Test: `creator_info_renders_all_fields`.
48pub const CREATOR_VERSION_PREFIX: &str = "creator:version=";
49
50/// Tag prefix carrying the originating subsystem (`http`/`mcp`/`hook`/`cli`).
51///
52/// Why: same labels as [`ActivitySource`] for HTTP / MCP / hook; CLI is
53/// a fourth value we accept here because drawers written from the
54/// `trusty-memory send-message` CLI never travel through the activity
55/// log emit path but still need attribution.
56/// What: lowercase string after the prefix.
57/// Test: `creator_info_renders_all_fields`.
58pub const CREATOR_SOURCE_PREFIX: &str = "creator:source=";
59
60/// Tag prefix carrying the writing process' cwd at write time
61/// (e.g. `creator:cwd=/Users/alice/projects/foo`).
62///
63/// Why: cwd is the single most useful clue when chasing noise — if a
64/// drawer carries `creator:cwd=/Users/alice/projects/claude-mpm`, the
65/// operator knows the write came from that working directory and can
66/// look at *what* was running there. Absent when the writer could not
67/// resolve a cwd (e.g. a remote HTTP client that did not send the
68/// optional header).
69/// Test: `creator_info_omits_cwd_when_absent`.
70pub const CREATOR_CWD_PREFIX: &str = "creator:cwd=";
71
72/// Tag prefix carrying the short session id of the writer (issue #202).
73///
74/// Why: when a session UUID is already attached as a bare tag, the TUI
75/// activity panel cannot easily pick it out of the tag list. Emitting a
76/// dedicated `creator:session=<first-8>` tag puts the session shorthand
77/// in the same reserved namespace as the rest of the attribution data so
78/// the dashboard / TUI can render it without bespoke parsing.
79/// What: prefix string; the suffix is the first 8 hex characters of the
80/// originating UUID.
81/// Test: `session_tag_from_tags_returns_first_uuid_short`.
82pub const CREATOR_SESSION_PREFIX: &str = "creator:session=";
83
84/// Tag prefix carrying the originating tm workstream's name (DOC-53).
85///
86/// Why: mirrors the rest of the `creator:*` namespace, but for the *workstream*
87/// (tm PM session) that wrote the drawer rather than the writing *client*
88/// binary. Lets operators (and the claim-drawer convention, DOC-53 §3) answer
89/// "which workstream wrote this?" the same grep-able way `creator:client=`
90/// answers "which binary wrote this?".
91/// What: rendered only when [`resolve_workstream_name`] returns `Some` — never
92/// a placeholder value.
93/// Test: `creator_info_renders_workstream_tags_when_resolvable`.
94pub const CREATOR_WORKSTREAM_PREFIX: &str = "creator:workstream=";
95
96/// Bare, non-namespaced tag carrying the same workstream name as
97/// [`CREATOR_WORKSTREAM_PREFIX`] (DOC-53 §4.2).
98///
99/// Why: `creator:*` tags are hidden from the primary tag chips
100/// ([`is_creator_tag`]) and from `memory_list`'s exact-tag filter unless the
101/// caller already knows the reserved-prefix form. A first-class `ws:<name>`
102/// tag lets `memory_list(tag: "ws:<name>")` find every drawer a workstream
103/// touched — both auto-stamped writes (this module) and hand-written claim
104/// drawers (DOC-53 §3.1), which use the identical `ws:<name>` tag by
105/// convention.
106/// What: always rendered together with `creator:workstream=`, never alone.
107/// Test: `creator_info_renders_workstream_tags_when_resolvable`.
108pub const WORKSTREAM_TAG_PREFIX: &str = "ws:";
109
110/// Environment variable carrying an explicit, human-readable workstream name
111/// (DOC-53 §4.3).
112///
113/// Why: `tm` does not currently export this — the only session-identity
114/// environment variable a managed session inherits today is
115/// `TM_MANAGED_SESSION_ID` (a UUID). This constant exists so resolution is
116/// forward-compatible: the day `tm` starts exporting a human-readable
117/// workstream name under this key, [`resolve_workstream_name`] picks it up
118/// with no code change here. Until then, resolution falls back to the
119/// cwd-derived heuristic (see that function).
120/// Test: `resolve_workstream_name_prefers_env_var`.
121pub const WORKSTREAM_NAME_ENV: &str = "TM_WORKSTREAM_NAME";
122
123/// HTTP request header carrying the writing client's short name.
124///
125/// Why: lets remote HTTP callers self-identify so the recipient daemon
126/// can populate `creator:client=` without guessing. The dashboard /
127/// claude-mpm / future trusty-* clients all set this when they make
128/// writes; clients that don't get the conservative fallback below.
129/// Test: `rpc_drawer_create_attributes_the_caller_it_was_given` — the
130/// header channel the three `drawer_creator_attribution_http_*` tests drove
131/// went with the listener (#6286); `CallerParams` carries the same three
132/// values in `params` and that test is what proves they reach the drawer.
133pub const X_TRUSTY_CLIENT_NAME: &str = "x-trusty-client-name";
134
135/// HTTP request header carrying the writing client's cwd.
136///
137/// Why: trusts the caller's self-reported cwd because the daemon has
138/// no other way to know it (the HTTP request originates from a remote
139/// process whose cwd is opaque). Absent header → `creator:cwd=` is
140/// omitted from the drawer tags rather than synthesised from the
141/// daemon's own cwd, which would be wrong.
142/// Test: `rpc_drawer_create_attributes_the_caller_it_was_given`.
143pub const X_TRUSTY_CLIENT_CWD: &str = "x-trusty-client-cwd";
144
145/// HTTP request header carrying the writing client's explicit workstream
146/// name (DOC-53 §4.3), the HTTP-transport counterpart of the MCP
147/// `args["workstream"]` field the stdio bridge injects
148/// (`commands::serve_stdio_bridge::inject_caller_context`).
149///
150/// Why: symmetric with [`X_TRUSTY_CLIENT_CWD`] — an HTTP caller that already
151/// knows its own workstream name (rather than relying on the
152/// `.worktrees/<name>` cwd-path heuristic [`resolve_workstream_name`]
153/// applies) can self-report it directly. Absent header → `creator:workstream=`
154/// falls back to the cwd heuristic, same precedence
155/// [`CreatorInfo::new_for_caller`] applies to the MCP path.
156/// Test: `rpc_drawer_create_attributes_the_caller_it_was_given`.
157pub const X_TRUSTY_CLIENT_WORKSTREAM: &str = "x-trusty-client-workstream";
158
159/// Default client name used when an HTTP caller omits the
160/// `X-Trusty-Client-Name` header.
161///
162/// Why: every drawer must carry a `creator:client=` tag so the
163/// dashboard renders a consistent "client" column; a missing header
164/// must not yield a missing tag. The fallback is verbose on purpose so
165/// operators can tell "the caller forgot to identify itself" apart from
166/// "the caller is a known trusty-* binary".
167/// Test: `rpc_drawer_create_attributes_the_caller_it_was_given`.
168pub const HTTP_DEFAULT_CLIENT: &str = "unknown-http-client";
169
170/// Client name attached to drawers written by the MCP tool surface.
171pub const MCP_CLIENT_NAME: &str = "trusty-memory-mcp";
172
173/// Client name attached to drawers written by the `trusty-memory` CLI.
174pub const CLI_CLIENT_NAME: &str = "trusty-memory-cli";
175
176/// Client name attached to drawers written by hook-driven code paths.
177///
178/// Why: hooks currently only read; the constant is reserved here so a
179/// future hook that *does* write a drawer (e.g. an inbox auto-archive)
180/// would tag itself consistently with the rest of the namespace.
181/// Test: `creator_info_renders_all_fields`.
182pub const HOOK_CLIENT_NAME: &str = "trusty-memory-hook";
183
184/// Originating-subsystem labels emitted into `creator:source=`.
185///
186/// Why: matches [`ActivitySource`] for HTTP/MCP/hook plus a fourth `cli`
187/// label that has no analogue on the activity-feed source enum (CLI
188/// writes go through the HTTP API, but the *origin* of the request was a
189/// CLI process; the user wants to see that distinction).
190/// What: stable lower-case strings.
191/// Test: `creator_info_renders_all_fields`.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum CreatorSource {
194    Http,
195    Mcp,
196    Hook,
197    Cli,
198}
199
200impl CreatorSource {
201    /// Stable lower-case string used in the `creator:source=` tag.
202    pub fn as_str(&self) -> &'static str {
203        match self {
204            Self::Http => "http",
205            Self::Mcp => "mcp",
206            Self::Hook => "hook",
207            Self::Cli => "cli",
208        }
209    }
210}
211
212impl From<ActivitySource> for CreatorSource {
213    fn from(s: ActivitySource) -> Self {
214        match s {
215            ActivitySource::Http => Self::Http,
216            ActivitySource::Mcp => Self::Mcp,
217            ActivitySource::Hook => Self::Hook,
218        }
219    }
220}
221
222/// Value type describing the writer of a drawer.
223///
224/// Why: each write path builds one of these and merges the rendered tags
225/// into the caller-supplied tag list before persisting. Keeping the
226/// rendering centralised guarantees every write produces tags in the
227/// same order with the same prefixes, so curl + grep workflows stay
228/// stable.
229/// What: holds an owned client name, an owned version string, the source
230/// enum, and an optional cwd. `into_tags()` consumes the value and
231/// returns the rendered tag list.
232/// Test: `creator_info_renders_all_fields`,
233/// `creator_info_omits_cwd_when_absent`.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct CreatorInfo {
236    pub client: String,
237    pub version: String,
238    pub source: CreatorSource,
239    pub cwd: Option<String>,
240    pub workstream: Option<String>,
241}
242
243impl CreatorInfo {
244    /// Build a `CreatorInfo` with the supplied client + source, defaulting
245    /// the version to this crate's `CARGO_PKG_VERSION` and the cwd/workstream
246    /// to whatever *this process* has at construction time.
247    ///
248    /// # ⚠️ Daemon-vs-caller hazard — read before calling this from a shared
249    /// # server dispatch path
250    ///
251    /// `new_self` resolves identity from the CALLING PROCESS' own
252    /// environment (`std::env::current_dir()`, `TM_WORKSTREAM_NAME`). That is
253    /// correct only when the process constructing the `CreatorInfo` genuinely
254    /// **is** the writer — a standalone CLI invocation, or a hook that runs
255    /// once per invocation in the caller's own process tree. It is **WRONG**
256    /// for any handler that runs inside `trusty-memory`'s shared HTTP/MCP
257    /// daemon (every `crate::tools::*` handler, reached via `POST /rpc` from
258    /// the stdio bridge or any other remote caller): the daemon is ONE
259    /// long-lived process serving MANY concurrently-attached sessions, so
260    /// `new_self` there would resolve the **daemon's own** cwd/env — the same
261    /// value for every caller — producing cross-session mis-attribution (the
262    /// exact bug DOC-53's caller-supplied design (`new_for_caller`) exists to
263    /// prevent). Use [`CreatorInfo::new_for_caller`] for any write reached
264    /// through the shared daemon's dispatch surface; reserve `new_self` for
265    /// code that truly executes in the writer's own process.
266    /// What: `client.into()` + `env!("CARGO_PKG_VERSION").into()` +
267    /// `std::env::current_dir().ok().map(...)` + [`resolve_own_workstream_name`].
268    /// Test: `creator_info_self_populates_version_and_cwd`.
269    pub fn new_self(client: impl Into<String>, source: CreatorSource) -> Self {
270        let cwd = std::env::current_dir()
271            .ok()
272            .map(|p| p.to_string_lossy().into_owned());
273        let workstream = resolve_own_workstream_name(cwd.as_deref());
274        Self {
275            client: client.into(),
276            version: env!("CARGO_PKG_VERSION").to_string(),
277            source,
278            cwd,
279            workstream,
280        }
281    }
282
283    /// Build a `CreatorInfo` for a write reached through the shared daemon's
284    /// dispatch surface (MCP `tools/call`/direct-method, or HTTP), using ONLY
285    /// caller-supplied context — never the daemon process' own env/cwd.
286    ///
287    /// Why (critical fix, DOC-53 §4.3): the daemon serves every
288    /// concurrently-attached session from ONE process. `new_self`'s
289    /// `std::env::current_dir()`/`TM_WORKSTREAM_NAME` reads would resolve the
290    /// *daemon's* identity, identically for every caller — this is the
291    /// constructor that instead trusts only what the specific request
292    /// carried, mirroring the existing `args["cwd"]` precedent in
293    /// `tools::palace_ops::handle_palace_create` (caller value wins; no
294    /// silent daemon-identity fallback for either field).
295    /// What: `cwd` is `caller_cwd` verbatim (empty-string treated as absent,
296    /// never re-derived from the daemon's own cwd). `workstream` prefers
297    /// `caller_workstream` when it passes [`is_valid_workstream_name`] — an
298    /// explicit-but-invalid value resolves to `None`, NOT a silent fallback
299    /// to the cwd heuristic (same "explicit-but-bad is `None`" rule
300    /// [`resolve_own_workstream_name`] already applies to the env var) —
301    /// else falls back to [`resolve_workstream_name`] against `caller_cwd`.
302    /// Neither field ever touches this process' own env or cwd.
303    /// Test: `new_for_caller_prefers_explicit_workstream_over_cwd`,
304    /// `new_for_caller_falls_back_to_cwd_when_workstream_absent`,
305    /// `new_for_caller_omits_workstream_when_neither_resolvable`,
306    /// `new_for_caller_invalid_explicit_workstream_returns_none_not_cwd_fallback`.
307    pub fn new_for_caller(
308        client: impl Into<String>,
309        source: CreatorSource,
310        caller_cwd: Option<&str>,
311        caller_workstream: Option<&str>,
312    ) -> Self {
313        let cwd = caller_cwd.map(str::to_string).filter(|c| !c.is_empty());
314        let workstream = match caller_workstream.filter(|w| !w.is_empty()) {
315            Some(w) => is_valid_workstream_name(w).then(|| w.to_string()),
316            None => resolve_workstream_name(cwd.as_deref()),
317        };
318        Self {
319            client: client.into(),
320            version: env!("CARGO_PKG_VERSION").to_string(),
321            source,
322            cwd,
323            workstream,
324        }
325    }
326
327    /// Render the rendered tag strings in stable order.
328    ///
329    /// Why: stable order keeps tests deterministic and gives operators a
330    /// predictable layout when they grep through palaces with `jq`.
331    /// What: `[client, version, source, cwd?, creator:workstream?, ws?]`.
332    /// `cwd` and the workstream pair are each omitted when absent rather
333    /// than rendered with an empty/placeholder value, so downstream
334    /// consumers can distinguish "writer didn't share this" from "writer's
335    /// value was literally empty" (DOC-53 §4.1 — no placeholder ever).
336    /// Test: `creator_info_renders_all_fields`,
337    /// `creator_info_omits_cwd_when_absent`,
338    /// `creator_info_renders_workstream_tags_when_resolvable`,
339    /// `creator_info_omits_workstream_tags_when_absent_or_invalid`.
340    pub fn into_tags(self) -> Vec<String> {
341        let mut out = Vec::with_capacity(6);
342        out.push(format!("{CREATOR_CLIENT_PREFIX}{}", self.client));
343        out.push(format!("{CREATOR_VERSION_PREFIX}{}", self.version));
344        out.push(format!("{CREATOR_SOURCE_PREFIX}{}", self.source.as_str()));
345        if let Some(cwd) = self.cwd.filter(|c| !c.is_empty()) {
346            out.push(format!("{CREATOR_CWD_PREFIX}{cwd}"));
347        }
348        if let Some(ws) = self.workstream.filter(|w| is_valid_workstream_name(w)) {
349            out.push(format!("{CREATOR_WORKSTREAM_PREFIX}{ws}"));
350            out.push(format!("{WORKSTREAM_TAG_PREFIX}{ws}"));
351        }
352        out
353    }
354
355    /// Render the tags and append them to an existing tag list.
356    ///
357    /// Why: write-path call sites already hold a `Vec<String>` of
358    /// user-supplied tags; merging in place avoids an allocation and
359    /// preserves the caller's ordering.
360    /// What: pushes each rendered tag onto `dst`. Does not deduplicate —
361    /// caller is expected to pass a freshly-built or de-duplicated list.
362    /// Test: `merge_into_appends_creator_tags`.
363    pub fn merge_into(self, dst: &mut Vec<String>) {
364        for tag in self.into_tags() {
365            dst.push(tag);
366        }
367    }
368
369    /// Render the tags and append them to an existing tag list, skipping any
370    /// tag already present verbatim in `dst`.
371    ///
372    /// Why (MEDIUM 1, DOC-53 §3.1): a hand-written claim drawer already
373    /// carries `ws:<name>` in its caller-supplied tags by convention (the
374    /// claim-drawer shape); `merge_into` would then append a *second*,
375    /// identical `ws:<name>` (and, since the caller-supplied workstream and
376    /// the auto-stamped one are the same value, an identical
377    /// `creator:workstream=<name>` too if the caller happened to write that
378    /// literal tag). Use this instead of `merge_into` for any write path
379    /// where the caller's own tags may already overlap the auto-stamped
380    /// namespace — currently [`crate::tools::helpers::attach_mcp_attribution`].
381    /// What: exact-string dedup only (not case-insensitive, not prefix-aware)
382    /// — a tag is skipped iff it already appears verbatim in `dst`.
383    /// Test: `merge_into_deduped_skips_tags_already_present`,
384    /// `merge_into_deduped_appends_when_no_overlap`.
385    pub fn merge_into_deduped(self, dst: &mut Vec<String>) {
386        for tag in self.into_tags() {
387            if !dst.contains(&tag) {
388                dst.push(tag);
389            }
390        }
391    }
392}
393
394/// Return `true` when a tag belongs to the `creator:*` reserved namespace.
395///
396/// Why: render paths (TUI, dashboard) want to hide attribution tags from
397/// the main tag chips so they don't clutter the UI alongside meaningful
398/// user-supplied tags (same pattern as `msg:*` hiding from #99). A single
399/// predicate keeps every renderer in lock-step.
400/// What: returns `tag.starts_with("creator:")`.
401/// Test: `is_creator_tag_detects_namespace`.
402pub fn is_creator_tag(tag: &str) -> bool {
403    tag.starts_with("creator:")
404}
405
406/// Resolve a workstream name from a cwd, if any (DOC-53 §4.3).
407///
408/// Why: the sole heuristic available for a cwd whose owner is not
409/// necessarily this process — a `.worktrees/<name>` path segment is already
410/// implicit in the cwd used for `creator:cwd=` (self-reported by an MCP/CLI
411/// call, or client-reported over HTTP via `X-Trusty-Client-Cwd`), so no new
412/// input is required to try it. Deliberately does **not** consult
413/// [`WORKSTREAM_NAME_ENV`] — that variable describes *this process'*
414/// environment, which is meaningless for a remote HTTP caller's cwd; only
415/// [`resolve_own_workstream_name`] (this process' own identity) reads it.
416/// What: the path segment immediately following a `.worktrees` component in
417/// `cwd`, gated by [`is_valid_workstream_name`] — an invalid candidate
418/// (empty, UUID-shaped, unsafe characters, too long) resolves to `None`,
419/// never a sanitized substitute.
420/// Test: `resolve_workstream_name_falls_back_to_worktrees_cwd_segment`,
421/// `resolve_workstream_name_rejects_uuid_shaped_cwd_segment`,
422/// `resolve_workstream_name_none_when_unresolvable`.
423pub fn resolve_workstream_name(cwd: Option<&str>) -> Option<String> {
424    let cwd = cwd?;
425    // #5204: match the CONFIGURED worktree base as well as the built-in
426    // `.worktrees`. Hardcoding the literal here silently dropped the
427    // `creator:workstream=` tag off every memory written after a retarget —
428    // and looked retroactive, since drawers written before it kept their tags.
429    let names = trusty_common::workspace_layout::WorktreeDirNames::resolve();
430    let mut components = cwd.split('/');
431    while let Some(part) = components.next() {
432        if names.matches(part) {
433            let candidate = components.next()?;
434            return is_valid_workstream_name(candidate).then(|| candidate.to_string());
435        }
436    }
437    None
438}
439
440/// Resolve *this process'* workstream name (DOC-53 §4.3).
441///
442/// Why: `tm` does not currently export a human-readable workstream-name
443/// environment variable to a managed session (only `TM_MANAGED_SESSION_ID`,
444/// a UUID) — see DOC-53 §4.3 for the investigation. Rather than block the
445/// feature on a session-launch change (a surface with several in-flight PRs
446/// at spec-authoring time), resolution uses only context this process
447/// already has: [`WORKSTREAM_NAME_ENV`] first (forward-compatible — the key
448/// `tm` would use if it starts exporting one), else the
449/// [`resolve_workstream_name`] cwd fallback. An env var that is *set but
450/// invalid* is treated as an explicit-but-bad signal and resolves to `None`
451/// rather than silently falling through to the cwd the caller never asked
452/// for.
453/// What: called from [`CreatorInfo::new_self`] (code that truly runs in its
454/// own writer process — see that method's doc for the daemon-vs-caller
455/// hazard) and from the MCP stdio bridge
456/// (`commands::serve_stdio_bridge::run_stdio_bridge`), which — unlike the
457/// shared daemon it proxies to — genuinely IS a fresh, per-session process,
458/// so resolving "this process' own identity" there is correct and is the
459/// mechanism that turns into the caller-supplied `args["workstream"]` the
460/// daemon-side [`CreatorInfo::new_for_caller`] then trusts. The HTTP write
461/// path (`web::rpc::creator_info_from_http`) and the daemon-side MCP
462/// dispatch handlers instead call [`resolve_workstream_name`] /
463/// [`CreatorInfo::new_for_caller`] directly against caller-supplied context,
464/// since the shared daemon's own environment says nothing about a specific
465/// caller's identity.
466/// Test: `resolve_workstream_name_prefers_env_var`,
467/// `resolve_workstream_name_invalid_env_var_returns_none`.
468pub(crate) fn resolve_own_workstream_name(cwd: Option<&str>) -> Option<String> {
469    if let Ok(name) = std::env::var(WORKSTREAM_NAME_ENV) {
470        return is_valid_workstream_name(&name).then_some(name);
471    }
472    resolve_workstream_name(cwd)
473}
474
475/// Validate a workstream-name candidate before it is ever rendered into a
476/// tag (DOC-53 §4.3).
477///
478/// Why: two independent hazards must both be rejected — an empty/overlong/
479/// unsafe-character candidate would corrupt the tag grammar, and a
480/// UUID-shaped candidate (the naming convention for ephemeral/anonymous
481/// scratch worktrees, as opposed to named workstream worktrees) would stamp
482/// noise indistinguishable from a real workstream name. Rejecting both means
483/// the caller omits the tag cleanly rather than sanitizing-and-including.
484/// What: non-empty, at most 64 bytes, matches
485/// `^[A-Za-z0-9][A-Za-z0-9_.-]*$`, and does not parse as a [`uuid::Uuid`].
486/// Test: `is_valid_workstream_name_accepts_slugs`,
487/// `is_valid_workstream_name_rejects_uuid_and_unsafe_names`.
488pub fn is_valid_workstream_name(name: &str) -> bool {
489    if name.is_empty() || name.len() > 64 {
490        return false;
491    }
492    if uuid::Uuid::parse_str(name).is_ok() {
493        return false;
494    }
495    let mut chars = name.chars();
496    let Some(first) = chars.next() else {
497        return false;
498    };
499    if !first.is_ascii_alphanumeric() {
500        return false;
501    }
502    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
503}
504
505/// Build a `creator:session=<first-8-chars>` tag from the first bare UUID
506/// found in `tags`, if any (issue #202).
507///
508/// Why: MCP writers (claude-mpm hooks, in particular) already pass the
509/// session UUID as a free-form tag in the `tags` array. Turning that into
510/// an explicit `creator:session=...` tag puts the session id alongside
511/// the rest of the attribution data so the dashboard / TUI can surface
512/// it without inspecting every tag for UUID-shaped strings.
513/// What: scans the slice in order, parses each entry with
514/// `uuid::Uuid::parse_str`, and on the first success returns
515/// `Some("creator:session=<first-8-hex>")`. Returns `None` when no entry
516/// parses as a UUID, or when the matching tag is itself already a
517/// `creator:*` tag (so dashboard-supplied creator tags don't get
518/// re-projected).
519/// Test: `session_tag_from_tags_returns_first_uuid_short`,
520/// `session_tag_from_tags_skips_non_uuid_entries`.
521pub fn session_tag_from_tags(tags: &[String]) -> Option<String> {
522    for tag in tags {
523        // Skip the reserved-namespace tags so a stray
524        // `creator:cwd=<uuid-shaped-path>` can never be misinterpreted
525        // as a session id. We only consider free-form bare tags.
526        if is_creator_tag(tag) {
527            continue;
528        }
529        if let Ok(uuid) = uuid::Uuid::parse_str(tag) {
530            // `uuid.simple()` renders as 32 lowercase hex chars; the
531            // first 8 are the same characters that appear before the
532            // first dash in the hyphenated form. Both forms parse to the
533            // same `Uuid`, so we render canonically here for stability.
534            let simple = uuid.simple().to_string();
535            let short: String = simple.chars().take(8).collect();
536            return Some(format!("{CREATOR_SESSION_PREFIX}{short}"));
537        }
538    }
539    None
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    /// Why: every render path must emit the four tags in stable order
547    /// (`client`, `version`, `source`, `cwd`) so dashboards can rely on
548    /// the layout. A regression that swapped two would silently change
549    /// every downstream consumer's parsing.
550    /// What: constructs a `CreatorInfo` with all fields populated and
551    /// asserts the rendered list.
552    /// Test: itself.
553    #[test]
554    fn creator_info_renders_all_fields() {
555        let info = CreatorInfo {
556            client: "qa-curl".into(),
557            version: "0.1.2".into(),
558            source: CreatorSource::Http,
559            cwd: Some("/tmp/proj".into()),
560            workstream: None,
561        };
562        let tags = info.into_tags();
563        assert_eq!(
564            tags,
565            vec![
566                "creator:client=qa-curl".to_string(),
567                "creator:version=0.1.2".to_string(),
568                "creator:source=http".to_string(),
569                "creator:cwd=/tmp/proj".to_string(),
570            ]
571        );
572    }
573
574    /// Why: absent cwd must produce three tags, not four with an empty
575    /// `cwd=` — that would force every parser to special-case the empty
576    /// suffix. Same for an empty-string cwd.
577    /// What: omits cwd and renders; then sets it to "" and renders.
578    /// Test: itself.
579    #[test]
580    fn creator_info_omits_cwd_when_absent() {
581        let info = CreatorInfo {
582            client: "mcp".into(),
583            version: "0.1.0".into(),
584            source: CreatorSource::Mcp,
585            cwd: None,
586            workstream: None,
587        };
588        assert_eq!(info.into_tags().len(), 3);
589
590        let info_empty = CreatorInfo {
591            client: "mcp".into(),
592            version: "0.1.0".into(),
593            source: CreatorSource::Mcp,
594            cwd: Some(String::new()),
595            workstream: None,
596        };
597        assert_eq!(info_empty.into_tags().len(), 3);
598    }
599
600    /// Why: `new_self` is the one-line convenience entry point most call
601    /// sites use; it must populate the version from the crate version and
602    /// the cwd from the running process so tests don't have to wire it up
603    /// by hand.
604    /// What: constructs and asserts version + cwd are non-empty. Serialises
605    /// on [`crate::commands::env_test_lock`] and clears
606    /// [`WORKSTREAM_NAME_ENV`] first since `new_self` now also resolves a
607    /// workstream name from the environment.
608    /// Test: itself.
609    #[tokio::test]
610    async fn creator_info_self_populates_version_and_cwd() {
611        let _guard = crate::commands::env_test_lock().lock().await;
612        // SAFETY: serialised by env_test_lock; only this var is touched.
613        unsafe {
614            std::env::remove_var(WORKSTREAM_NAME_ENV);
615        }
616        let info = CreatorInfo::new_self("client", CreatorSource::Cli);
617        assert!(!info.version.is_empty(), "version must be populated");
618        assert!(info.cwd.is_some(), "cwd should resolve in tests");
619    }
620
621    /// Why: the merge helper exists so call sites with an existing tag
622    /// vec don't have to allocate; the contract is "appends in stable
623    /// order".
624    /// What: starts with one caller-supplied tag, merges, asserts the
625    /// trailing tags are the creator tags in order.
626    /// Test: itself.
627    #[test]
628    fn merge_into_appends_creator_tags() {
629        let mut tags = vec!["user-supplied".to_string()];
630        CreatorInfo {
631            client: "x".into(),
632            version: "1".into(),
633            source: CreatorSource::Cli,
634            cwd: None,
635            workstream: None,
636        }
637        .merge_into(&mut tags);
638        assert_eq!(
639            tags,
640            vec![
641                "user-supplied".to_string(),
642                "creator:client=x".to_string(),
643                "creator:version=1".to_string(),
644                "creator:source=cli".to_string(),
645            ]
646        );
647    }
648
649    /// Why: dashboards / TUI renderers must hide `creator:*` tags from
650    /// the main tag chips so the user-supplied tags remain prominent.
651    /// What: tests true / false cases against the predicate.
652    /// Test: itself.
653    #[test]
654    fn is_creator_tag_detects_namespace() {
655        assert!(is_creator_tag("creator:client=foo"));
656        assert!(is_creator_tag("creator:cwd=/tmp"));
657        assert!(is_creator_tag(CREATOR_VERSION_PREFIX));
658        assert!(!is_creator_tag("user-tag"));
659        assert!(!is_creator_tag("msg:v1"));
660        assert!(!is_creator_tag("creatorx"));
661    }
662
663    /// Why: issue #202 — MCP writers (claude-mpm hooks) commonly pass
664    /// the session UUID as a bare tag in the `tags` array. The helper
665    /// must pick out the first parseable UUID and emit the short form
666    /// in the reserved `creator:session=` namespace so the TUI activity
667    /// panel renders it without bespoke parsing.
668    /// What: feeds a mixed tag list and asserts the first 8 hex chars
669    /// of the UUID round-trip into the returned tag.
670    /// Test: itself.
671    #[test]
672    fn session_tag_from_tags_returns_first_uuid_short() {
673        let tags = vec![
674            "user-tag".to_string(),
675            "01919e90-8a2e-7c1d-9f8b-1234567890ab".to_string(),
676            "ignored-second-uuid:11111111-2222-3333-4444-555555555555".to_string(),
677        ];
678        let session = session_tag_from_tags(&tags).expect("session tag");
679        assert_eq!(session, "creator:session=01919e90");
680    }
681
682    /// Why: non-UUID entries (free-form tags, scoped tags like `idx:0`)
683    /// must not be misinterpreted as session ids — the helper has to
684    /// return `None` when no entry parses as a UUID.
685    /// What: feeds a tag list with no UUIDs and asserts `None`.
686    /// Test: itself.
687    #[test]
688    fn session_tag_from_tags_skips_non_uuid_entries() {
689        let tags = vec![
690            "user-tag".to_string(),
691            "idx:0".to_string(),
692            "session-prefix-not-a-uuid".to_string(),
693        ];
694        assert!(session_tag_from_tags(&tags).is_none());
695
696        // Empty list returns `None`.
697        assert!(session_tag_from_tags(&[]).is_none());
698    }
699
700    /// Why: a tag in the reserved `creator:*` namespace must never be
701    /// re-projected as a session id, even if its value parses as a UUID.
702    /// `creator:cwd=` carrying a UUID-shaped temporary path is the
703    /// motivating example.
704    /// What: feeds a `creator:` tag whose value parses as a UUID and a
705    /// real bare UUID later in the list, then asserts the real one wins.
706    /// Test: itself.
707    #[test]
708    fn session_tag_from_tags_skips_reserved_namespace() {
709        let tags = vec![
710            // Reserved namespace tag with a UUID-shaped value — must be skipped.
711            "creator:cwd=11111111-1111-1111-1111-111111111111".to_string(),
712            // The real session tag — must win.
713            "22222222-2222-2222-2222-222222222222".to_string(),
714        ];
715        let session = session_tag_from_tags(&tags).expect("session tag");
716        assert_eq!(session, "creator:session=22222222");
717    }
718
719    /// Why: the `From<ActivitySource>` impl lets the HTTP path build a
720    /// `CreatorSource` from the existing `ActivitySource::Http` without
721    /// a manual match; the mapping must be identity for the three shared
722    /// variants.
723    /// What: round-trips each variant.
724    /// Test: itself.
725    #[test]
726    fn creator_source_from_activity_source() {
727        assert_eq!(
728            CreatorSource::from(ActivitySource::Http),
729            CreatorSource::Http
730        );
731        assert_eq!(CreatorSource::from(ActivitySource::Mcp), CreatorSource::Mcp);
732        assert_eq!(
733            CreatorSource::from(ActivitySource::Hook),
734            CreatorSource::Hook
735        );
736    }
737
738    /// Why: `into_tags` must render both the reserved `creator:workstream=`
739    /// tag and the ergonomic bare `ws:` tag, in that order, immediately
740    /// after `cwd` — DOC-53 §4.1/§4.2.
741    /// What: constructs a `CreatorInfo` with a resolvable workstream and
742    /// asserts both trailing tags.
743    /// Test: itself.
744    #[test]
745    fn creator_info_renders_workstream_tags_when_resolvable() {
746        let info = CreatorInfo {
747            client: "mcp".into(),
748            version: "0.1.0".into(),
749            source: CreatorSource::Mcp,
750            cwd: Some("/tmp/proj".into()),
751            workstream: Some("feat-ws-memory-claims".into()),
752        };
753        let tags = info.into_tags();
754        assert_eq!(
755            tags,
756            vec![
757                "creator:client=mcp".to_string(),
758                "creator:version=0.1.0".to_string(),
759                "creator:source=mcp".to_string(),
760                "creator:cwd=/tmp/proj".to_string(),
761                "creator:workstream=feat-ws-memory-claims".to_string(),
762                "ws:feat-ws-memory-claims".to_string(),
763            ]
764        );
765    }
766
767    /// Why: DOC-53 §4.1's "no placeholder, ever" rule must hold both when
768    /// `workstream` is `None` (the common case) AND when a caller manually
769    /// sets an invalid value — `into_tags` must re-validate rather than
770    /// trust its input, since [`CreatorInfo`] is a public struct any caller
771    /// can construct directly.
772    /// What: `None` renders no trailing tags; an unsafe/UUID-shaped value
773    /// also renders none, rather than being sanitized and included.
774    /// Test: itself.
775    #[test]
776    fn creator_info_omits_workstream_tags_when_absent_or_invalid() {
777        let absent = CreatorInfo {
778            client: "mcp".into(),
779            version: "0.1.0".into(),
780            source: CreatorSource::Mcp,
781            cwd: None,
782            workstream: None,
783        };
784        assert_eq!(absent.into_tags().len(), 3);
785
786        let invalid = CreatorInfo {
787            client: "mcp".into(),
788            version: "0.1.0".into(),
789            source: CreatorSource::Mcp,
790            cwd: None,
791            workstream: Some("11111111-1111-1111-1111-111111111111".into()),
792        };
793        assert_eq!(invalid.into_tags().len(), 3);
794    }
795
796    /// Why (CRITICAL fix, DOC-53 §4.3): `new_for_caller` is the ONLY
797    /// constructor the shared daemon's dispatch handlers should use — it
798    /// must never touch this process' own env/cwd, only what the caller
799    /// supplied. This is the base case: an explicit, valid workstream wins
800    /// even when a plausible cwd fallback is also present.
801    /// What: supplies both a caller cwd and a distinct caller workstream;
802    /// asserts the explicit workstream is used, not one derived from cwd.
803    /// Test: itself.
804    #[test]
805    fn new_for_caller_prefers_explicit_workstream_over_cwd() {
806        let info = CreatorInfo::new_for_caller(
807            "trusty-memory-mcp",
808            CreatorSource::Mcp,
809            Some("/x/.worktrees/cwd-derived-name"),
810            Some("explicit-ws"),
811        );
812        assert_eq!(info.workstream.as_deref(), Some("explicit-ws"));
813        assert_eq!(info.cwd.as_deref(), Some("/x/.worktrees/cwd-derived-name"));
814    }
815
816    /// Why: when the caller omits `workstream` entirely (but supplies
817    /// `cwd`), the cwd-derived heuristic is the correct fallback — same
818    /// derivation [`resolve_workstream_name`] already provides for the HTTP
819    /// path.
820    /// What: caller workstream `None`, caller cwd a `.worktrees/<name>`
821    /// path; asserts the derived name.
822    /// Test: itself.
823    #[test]
824    fn new_for_caller_falls_back_to_cwd_when_workstream_absent() {
825        let info = CreatorInfo::new_for_caller(
826            "trusty-memory-mcp",
827            CreatorSource::Mcp,
828            Some("/x/.worktrees/cwd-derived-name"),
829            None,
830        );
831        assert_eq!(info.workstream.as_deref(), Some("cwd-derived-name"));
832    }
833
834    /// Why: no caller cwd AND no caller workstream is the honest "we don't
835    /// know" case — DOC-53 §4.1's omit-cleanly rule, at the caller-supplied
836    /// constructor.
837    /// What: both `None`; asserts `workstream` is `None` (never falls back
838    /// to this process' own identity).
839    /// Test: itself.
840    #[test]
841    fn new_for_caller_omits_workstream_when_neither_resolvable() {
842        let info = CreatorInfo::new_for_caller("trusty-memory-mcp", CreatorSource::Mcp, None, None);
843        assert_eq!(info.workstream, None);
844        assert_eq!(info.cwd, None);
845    }
846
847    /// Why: an explicit-but-invalid caller workstream (unsafe chars,
848    /// UUID-shaped, empty) is an explicit-but-bad signal — DOC-53 §4.3
849    /// treats that as `None`, NOT a silent fallback to the cwd heuristic the
850    /// caller didn't ask for (mirrors [`resolve_own_workstream_name`]'s
851    /// identical rule for the env var).
852    /// What: an invalid explicit workstream alongside a valid cwd fallback;
853    /// asserts `None`, not the cwd-derived value.
854    /// Test: itself.
855    #[test]
856    fn new_for_caller_invalid_explicit_workstream_returns_none_not_cwd_fallback() {
857        let info = CreatorInfo::new_for_caller(
858            "trusty-memory-mcp",
859            CreatorSource::Mcp,
860            Some("/x/.worktrees/cwd-derived-name"),
861            Some("not a valid name!"),
862        );
863        assert_eq!(info.workstream, None);
864    }
865
866    /// Why (MEDIUM 1, DOC-53 §3.1): a hand-written claim drawer's own tags
867    /// may already carry `ws:<name>` (and, less commonly, a literal
868    /// `creator:workstream=<name>`) — `merge_into_deduped` must not append a
869    /// second copy of either.
870    /// What: seeds `dst` with `ws:feat-x` already present, merges a
871    /// `CreatorInfo` whose rendered tags include `ws:feat-x`, asserts the
872    /// tag appears exactly once while the OTHER rendered tags (which were
873    /// not already present) still land.
874    /// Test: itself.
875    #[test]
876    fn merge_into_deduped_skips_tags_already_present() {
877        let mut tags = vec!["user-tag".to_string(), "ws:feat-x".to_string()];
878        CreatorInfo {
879            client: "trusty-memory-mcp".into(),
880            version: "0.1.0".into(),
881            source: CreatorSource::Mcp,
882            cwd: None,
883            workstream: Some("feat-x".into()),
884        }
885        .merge_into_deduped(&mut tags);
886        assert_eq!(
887            tags.iter().filter(|t| *t == "ws:feat-x").count(),
888            1,
889            "ws:feat-x must not be duplicated; got {tags:?}"
890        );
891        assert!(
892            tags.contains(&"creator:client=trusty-memory-mcp".to_string()),
893            "non-overlapping tags must still be appended; got {tags:?}"
894        );
895        assert!(
896            tags.contains(&"creator:workstream=feat-x".to_string()),
897            "creator:workstream= must still be appended (only ws: overlapped); got {tags:?}"
898        );
899    }
900
901    /// Why: dedup must not become a no-append bug — when nothing overlaps,
902    /// every rendered tag lands exactly as `merge_into` would produce.
903    /// What: seeds `dst` with an unrelated tag only; asserts all rendered
904    /// tags are present.
905    /// Test: itself.
906    #[test]
907    fn merge_into_deduped_appends_when_no_overlap() {
908        let mut tags = vec!["unrelated".to_string()];
909        CreatorInfo {
910            client: "trusty-memory-mcp".into(),
911            version: "0.1.0".into(),
912            source: CreatorSource::Mcp,
913            cwd: None,
914            workstream: Some("feat-x".into()),
915        }
916        .merge_into_deduped(&mut tags);
917        assert_eq!(
918            tags,
919            vec![
920                "unrelated".to_string(),
921                "creator:client=trusty-memory-mcp".to_string(),
922                "creator:version=0.1.0".to_string(),
923                "creator:source=mcp".to_string(),
924                "creator:workstream=feat-x".to_string(),
925                "ws:feat-x".to_string(),
926            ]
927        );
928    }
929
930    /// Why: [`WORKSTREAM_NAME_ENV`] is the forward-compatible primary
931    /// source (DOC-53 §4.3) and must win over the cwd fallback whenever
932    /// set. Serialises on `env_test_lock` since it mutates process-wide
933    /// state.
934    /// What: sets the env var, passes an unrelated (even a `.worktrees`-
935    /// bearing) cwd, and asserts the env value wins.
936    /// Test: itself.
937    #[tokio::test]
938    async fn resolve_workstream_name_prefers_env_var() {
939        let _guard = crate::commands::env_test_lock().lock().await;
940        // SAFETY: serialised by env_test_lock; only this var is touched,
941        // and it is always cleared before returning.
942        unsafe {
943            std::env::set_var(WORKSTREAM_NAME_ENV, "explicit-ws");
944        }
945        let resolved = resolve_own_workstream_name(Some("/x/.worktrees/other-name"));
946        unsafe {
947            std::env::remove_var(WORKSTREAM_NAME_ENV);
948        }
949        assert_eq!(resolved, Some("explicit-ws".to_string()));
950    }
951
952    /// Why: an env var that is SET but fails validation (DOC-53 §4.3) is an
953    /// explicit-but-bad signal — it must resolve to `None`, not silently
954    /// fall through to the cwd heuristic the writer never asked for.
955    /// What: sets an unsafe env value with a plausible cwd fallback present
956    /// and asserts `None`.
957    /// Test: itself.
958    #[tokio::test]
959    async fn resolve_workstream_name_invalid_env_var_returns_none() {
960        let _guard = crate::commands::env_test_lock().lock().await;
961        // SAFETY: serialised by env_test_lock.
962        unsafe {
963            std::env::set_var(WORKSTREAM_NAME_ENV, "not a valid name!");
964        }
965        let resolved = resolve_own_workstream_name(Some("/x/.worktrees/other-name"));
966        unsafe {
967            std::env::remove_var(WORKSTREAM_NAME_ENV);
968        }
969        assert_eq!(resolved, None);
970    }
971
972    /// Why: the common case — a `tm`-managed PM session running directly in
973    /// its own `.worktrees/<name>` checkout — must resolve without any `tm`
974    /// changes (DOC-53 §4.3). [`resolve_workstream_name`] is pure cwd-based
975    /// (no env var involved, see its doc comment), so no locking is needed.
976    /// What: feeds a realistic worktree cwd, asserts the segment
977    /// immediately after `.worktrees` is returned.
978    /// Test: itself.
979    #[test]
980    fn resolve_workstream_name_falls_back_to_worktrees_cwd_segment() {
981        let resolved = resolve_workstream_name(Some(
982            "/Users/bob/trusty-tools/.base/.worktrees/feat-ws-memory-claims",
983        ));
984        assert_eq!(resolved, Some("feat-ws-memory-claims".to_string()));
985    }
986
987    /// Why: ephemeral/anonymous scratch worktrees are named by UUID, not by
988    /// workstream (DOC-53 §4.3) — stamping one would produce noise
989    /// indistinguishable from a real name, so it must resolve to `None`.
990    /// What: feeds a UUID-named `.worktrees/<uuid>` cwd, asserts `None`.
991    /// Test: itself.
992    #[test]
993    fn resolve_workstream_name_rejects_uuid_shaped_cwd_segment() {
994        let resolved = resolve_workstream_name(Some(
995            "/Users/bob/trusty-tools/.base/.worktrees/2eb72dca-de08-481b-8dfa-22ab7f81b1f9",
996        ));
997        assert_eq!(resolved, None);
998    }
999
1000    /// Why: a cwd with no `.worktrees` component (or no cwd at all) is the
1001    /// honest "can't resolve this" case — DOC-53 §4.1's omit-cleanly rule,
1002    /// exercised at the resolver level.
1003    /// What: cwd `None`; then a cwd with no `.worktrees` segment. Both
1004    /// resolve to `None`.
1005    /// Test: itself.
1006    #[test]
1007    fn resolve_workstream_name_none_when_unresolvable() {
1008        assert_eq!(resolve_workstream_name(None), None);
1009        assert_eq!(
1010            resolve_workstream_name(Some("/Users/bob/some/other/project")),
1011            None
1012        );
1013    }
1014
1015    /// Why: the validator is the single gate standing between untrusted
1016    /// input (env var or cwd segment) and a rendered tag — it must accept
1017    /// ordinary slugs.
1018    /// What: table of accepted names.
1019    /// Test: itself.
1020    #[test]
1021    fn is_valid_workstream_name_accepts_slugs() {
1022        for name in [
1023            "feat-ws-memory-claims",
1024            "tm_search_eviction_01",
1025            "a",
1026            "release.0.20.0",
1027        ] {
1028            assert!(is_valid_workstream_name(name), "expected valid: {name}");
1029        }
1030    }
1031
1032    /// Why: the validator must reject both structurally-unsafe candidates
1033    /// (empty, overlong, unsafe characters, non-alnum leading char) and
1034    /// UUID-shaped candidates (DOC-53 §4.3's anonymous-worktree exclusion),
1035    /// since a caller (env var or cwd) is untrusted input.
1036    /// What: table of rejected names.
1037    /// Test: itself.
1038    #[test]
1039    fn is_valid_workstream_name_rejects_uuid_and_unsafe_names() {
1040        for name in [
1041            "",
1042            "2eb72dca-de08-481b-8dfa-22ab7f81b1f9",
1043            "-leading-dash",
1044            "has space",
1045            "has/slash",
1046            "has;semicolon",
1047            &"x".repeat(65),
1048        ] {
1049            assert!(!is_valid_workstream_name(name), "expected invalid: {name}");
1050        }
1051    }
1052}