@@ -26,9 +26,10 @@ use serde_json::{Map, Value};
2626use crate :: accounting:: parser:: parse_timestamp;
2727use crate :: sessions:: SessionMessageRecord ;
2828use crate :: sessions:: shared:: {
29- ProjectRootMatcherCache , StoredCursor , TranscriptLocation , TranscriptLocationMetadataKeys ,
30- append_location_metadata_cached, append_tool_calls_metadata, append_tool_event_metadata,
31- append_usage_metadata, content_storage_text_and_tools, preview_truncated, title_from_messages,
29+ ProjectMembership , ProjectRootMatcherCache , StoredCursor , TranscriptLocation ,
30+ TranscriptLocationMetadataKeys , append_location_metadata_cached, append_tool_calls_metadata,
31+ append_tool_event_metadata, append_usage_metadata, content_storage_text_and_tools,
32+ preview_truncated, title_from_messages,
3233} ;
3334use crate :: sessions:: source:: {
3435 ParsedTranscript , SessionDraft , TranscriptSource , collect_files_with_ext, stream_new_jsonl,
@@ -210,22 +211,26 @@ impl TranscriptSource for ClaudeSource {
210211 for line in & new. lines {
211212 let record = & line. value ;
212213 let line_cwd = record_cwd ( record) . or_else ( || session_cwd. clone ( ) ) ;
213- let include = self . user_scope . as_ref ( ) . map_or_else (
214- || {
215- line_cwd. as_deref ( ) . is_some_and ( |cwd| {
216- project_matcher
217- . as_ref ( )
218- . is_some_and ( |matcher| matcher. contains ( cwd) )
219- } )
220- } ,
221- |_scope| {
222- line_cwd. as_deref ( ) . is_none_or ( |cwd| {
223- !registered_root_matchers
224- . iter ( )
225- . any ( |matcher| matcher. contains ( cwd) )
226- } )
227- } ,
228- ) ;
214+ let include = if self . user_scope . is_none ( ) {
215+ line_cwd. as_deref ( ) . map_or ( Some ( false ) , |cwd| {
216+ project_matcher
217+ . as_ref ( )
218+ . map ( |matcher| matcher. contains_status ( cwd) . definitive ( ) )
219+ . unwrap_or ( Some ( false ) )
220+ } )
221+ } else {
222+ line_cwd. as_deref ( ) . map_or ( Some ( true ) , |cwd| {
223+ let mut unknown = false ;
224+ for matcher in & registered_root_matchers {
225+ match matcher. contains_status ( cwd) {
226+ ProjectMembership :: Match => return Some ( false ) ,
227+ ProjectMembership :: NoMatch => { }
228+ ProjectMembership :: Unknown => unknown = true ,
229+ }
230+ }
231+ ( !unknown) . then_some ( true )
232+ } )
233+ } ?;
229234 if !include {
230235 continue ;
231236 }
@@ -1362,9 +1367,27 @@ fn record_cwd(record: &Value) -> Option<PathBuf> {
13621367
13631368#[ cfg( test) ]
13641369mod tests {
1370+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
1371+
13651372 use super :: * ;
13661373 use serde_json:: json;
13671374
1375+ static UNKNOWN_PATH_ATTEMPTS : AtomicUsize = AtomicUsize :: new ( 0 ) ;
1376+
1377+ fn retrying_identity ( path : & Path ) -> crate :: worktree:: GitRepoIdentityOutcome {
1378+ let root = path
1379+ . ancestors ( )
1380+ . find ( |ancestor| ancestor. file_name ( ) . is_some_and ( |name| name == "repo" ) )
1381+ . unwrap_or ( path) ;
1382+ if UNKNOWN_PATH_ATTEMPTS . fetch_add ( 1 , Ordering :: SeqCst ) == 0 {
1383+ return crate :: worktree:: GitRepoIdentityOutcome :: Unknown ;
1384+ }
1385+ crate :: worktree:: GitRepoIdentityOutcome :: Resolved ( crate :: worktree:: GitRepoIdentity {
1386+ worktree_root : root. to_path_buf ( ) ,
1387+ common_dir : root. join ( ".git" ) ,
1388+ } )
1389+ }
1390+
13681391 #[ test]
13691392 fn structured_git_operation_becomes_host_commit_evidence ( ) {
13701393 let mut metadata = Map :: new ( ) ;
@@ -1508,6 +1531,47 @@ mod tests {
15081531 assert_eq ! ( second_metadata[ "claude_message_worktree" ] , first_worktree) ;
15091532 }
15101533
1534+ #[ test]
1535+ fn claude_unknown_membership_retries_without_advancing_cursor ( ) {
1536+ UNKNOWN_PATH_ATTEMPTS . store ( 0 , Ordering :: SeqCst ) ;
1537+ let temp = tempfile:: TempDir :: new ( ) . expect ( "temp dir" ) ;
1538+ let project_root = temp. path ( ) . join ( "repo" ) ;
1539+ let nested_cwd = project_root. join ( "packages/app" ) ;
1540+ std:: fs:: create_dir_all ( & nested_cwd) . expect ( "nested cwd" ) ;
1541+ let transcript = temp. path ( ) . join ( "retry.jsonl" ) ;
1542+ std:: fs:: write (
1543+ & transcript,
1544+ format ! (
1545+ "{}\n " ,
1546+ json!( {
1547+ "type" : "user" ,
1548+ "sessionId" : "retry" ,
1549+ "cwd" : nested_cwd,
1550+ "message" : { "role" : "user" , "content" : "retry me" }
1551+ } )
1552+ ) ,
1553+ )
1554+ . expect ( "write transcript" ) ;
1555+ let mut source = ClaudeSource :: with_home ( temp. path ( ) ) ;
1556+ source. project_matchers =
1557+ ProjectRootMatcherCache :: with_identity_resolver ( retrying_identity) ;
1558+
1559+ let previous = StoredCursor :: default ( ) ;
1560+ assert ! (
1561+ source
1562+ . parse_new( & transcript, previous, & project_root, None )
1563+ . is_none( ) ,
1564+ "unknown membership must abort before a new cursor can be persisted"
1565+ ) ;
1566+
1567+ let retried = source
1568+ . parse_new ( & transcript, previous, & project_root, None )
1569+ . expect ( "unknown membership must be resolved again on retry" ) ;
1570+ assert_eq ! ( retried. messages. len( ) , 1 ) ;
1571+ assert ! ( retried. new_cursor. position > previous. position) ;
1572+ assert_eq ! ( UNKNOWN_PATH_ATTEMPTS . load( Ordering :: SeqCst ) , 3 ) ;
1573+ }
1574+
15111575 #[ test]
15121576 fn redacted_only_thinking_records_no_reasoning_row ( ) {
15131577 // Matches Codex's encrypted-reasoning convention: no plaintext, no row.
0 commit comments