diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
index 6dc58649..9e8358b6 100644
--- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
+++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
@@ -22,6 +22,7 @@ public sealed class PlanningSessionManager
private readonly GitService _git;
private readonly WorkerConfig _cfg;
private readonly string _rootDirectory;
+ private readonly string _projectsRoot;
private readonly ITaskStateService? _state;
private readonly PlanningChainCoordinator? _chain;
@@ -32,7 +33,8 @@ public sealed class PlanningSessionManager
WorkerConfig cfg,
ITaskStateService state,
PlanningChainCoordinator chain,
- string rootDirectory)
+ string rootDirectory,
+ string? projectsRoot = null)
{
_factory = factory;
_git = git;
@@ -40,6 +42,7 @@ public sealed class PlanningSessionManager
_state = state;
_chain = chain;
_rootDirectory = rootDirectory;
+ _projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
}
// Test constructor.
@@ -51,7 +54,8 @@ public sealed class PlanningSessionManager
WorkerConfig cfg,
string rootDirectory,
ITaskStateService? state = null,
- PlanningChainCoordinator? chain = null)
+ PlanningChainCoordinator? chain = null,
+ string? projectsRoot = null)
{
_tasksOverride = tasks;
_listsOverride = lists;
@@ -61,6 +65,7 @@ public sealed class PlanningSessionManager
_state = state;
_chain = chain;
_rootDirectory = rootDirectory;
+ _projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
}
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
@@ -235,8 +240,6 @@ public sealed class PlanningSessionManager
if (task.PlanningPhase != PlanningPhase.Active)
throw new InvalidOperationException(
$"Task planning phase is {task.PlanningPhase}; resume requires Active planning.");
- if (string.IsNullOrEmpty(task.PlanningSessionId))
- throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (!Directory.Exists(sessionDir))
@@ -252,12 +255,26 @@ public sealed class PlanningSessionManager
if (!Directory.Exists(worktreePath))
throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");
+ // The planning session runs in an interactive TUI, which never reports its session id
+ // back to us — so nothing ever wrote planning_session_id. Recover it from the transcript
+ // Claude Code leaves on disk for the planning worktree, and persist it so the next
+ // resume is a plain DB read.
+ var sessionId = task.PlanningSessionId;
+ if (string.IsNullOrEmpty(sessionId))
+ {
+ sessionId = PlanningTranscriptLocator.FindSessionId(worktreePath, _projectsRoot)
+ ?? throw new InvalidOperationException(
+ "No Claude session transcript found for this planning worktree; cannot resume. " +
+ "Finalize or discard the planning session instead.");
+ await tasks.UpdatePlanningSessionIdAsync(taskId, sessionId, ct);
+ }
+
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
return new PlanningSessionResumeContext(
ParentTaskId: taskId,
WorkingDir: worktreePath,
- ClaudeSessionId: task.PlanningSessionId,
+ ClaudeSessionId: sessionId,
Token: token,
WorktreePath: worktreePath);
}
@@ -387,4 +404,40 @@ public sealed class PlanningSessionManager
throw new InvalidOperationException($"Token file missing: {path}");
return (await File.ReadAllTextAsync(path, ct)).Trim();
}
+}
+
+///
+/// Recovers the Claude session id of an interactive session from the transcript Claude Code
+/// writes to ~/.claude/projects/<encoded cwd>/<sessionId>.jsonl. A planning
+/// worktree is used by exactly one session, so the newest transcript in that folder is it.
+///
+///
+/// The folder-name encoding is undocumented CLI behaviour (every non-alphanumeric character in
+/// the absolute path becomes '-'). If it ever changes, returns null
+/// and the caller reports "cannot resume" instead of resuming the wrong session.
+///
+public static class PlanningTranscriptLocator
+{
+ public static string? FindSessionId(string worktreePath, string projectsRoot)
+ {
+ var dir = Path.Combine(projectsRoot, EncodeCwd(worktreePath));
+ if (!Directory.Exists(dir)) return null;
+
+ var newest = new DirectoryInfo(dir)
+ .EnumerateFiles("*.jsonl")
+ .OrderByDescending(f => f.LastWriteTimeUtc)
+ .FirstOrDefault();
+
+ return newest is null ? null : Path.GetFileNameWithoutExtension(newest.Name);
+ }
+
+ private static string EncodeCwd(string path)
+ {
+ var full = Path.GetFullPath(path);
+ return string.Create(full.Length, full, static (span, src) =>
+ {
+ for (var i = 0; i < src.Length; i++)
+ span[i] = char.IsAsciiLetterOrDigit(src[i]) ? src[i] : '-';
+ });
+ }
}
\ No newline at end of file
diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
index 6fcb7162..1e273113 100644
--- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
+++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
@@ -21,9 +21,11 @@ public sealed class PlanningSessionManagerTests : IDisposable
private readonly AppSettingsRepository _settingsRepo;
private readonly PlanningSessionManager _sut;
private readonly TaskStateServiceBuilder.Built _built;
+ private readonly string _projectsRoot;
public PlanningSessionManagerTests()
{
+ _projectsRoot = Path.Combine(Path.GetTempPath(), $"cd_projects_{Guid.NewGuid():N}");
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
@@ -34,7 +36,8 @@ public sealed class PlanningSessionManagerTests : IDisposable
_settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
_built = TaskStateServiceBuilder.Build(_db.CreateFactory());
_sut = new PlanningSessionManager(
- _tasks, _lists, _settingsRepo, _git, _cfg, _rootDir, _built.State, _built.Chain);
+ _tasks, _lists, _settingsRepo, _git, _cfg, _rootDir, _built.State, _built.Chain,
+ _projectsRoot);
}
public void Dispose()
@@ -42,6 +45,20 @@ public sealed class PlanningSessionManagerTests : IDisposable
_ctx.Dispose();
_db.Dispose();
try { Directory.Delete(_rootDir, recursive: true); } catch { /* ignore */ }
+ try { Directory.Delete(_projectsRoot, recursive: true); } catch { /* ignore */ }
+ }
+
+ // Mirrors what Claude Code writes: ~/.claude/projects//.jsonl
+ private void WriteTranscript(string cwd, string sessionId, DateTime lastWriteUtc)
+ {
+ var encoded = string.Concat(Path.GetFullPath(cwd)
+ .Select(c => char.IsAsciiLetterOrDigit(c) ? c : '-'));
+ var dir = Path.Combine(_projectsRoot, encoded);
+ Directory.CreateDirectory(dir);
+ var file = Path.Combine(dir, $"{sessionId}.jsonl");
+ File.WriteAllText(file, "{}\n");
+ File.SetLastWriteTimeUtc(file, lastWriteUtc);
}
private async Task<(string listId, string workingDir)> SeedListAsync()
@@ -165,17 +182,52 @@ public sealed class PlanningSessionManagerTests : IDisposable
}
[Fact]
- public async Task ResumeAsync_NoClaudeSessionId_Throws()
+ public async Task ResumeAsync_NoSessionIdAndNoTranscript_Throws()
{
var (listId, _) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
await _sut.StartAsync(parent.Id, CancellationToken.None);
- // UpdatePlanningSessionIdAsync not called
+ // Nothing persisted planning_session_id, and no transcript exists on disk.
await Assert.ThrowsAsync(() =>
_sut.ResumeAsync(parent.Id, CancellationToken.None));
}
+ // The interactive TUI never reports its session id, so planning_session_id stays NULL.
+ // Resume has to recover it from the transcript Claude Code left for the planning worktree.
+ [Fact]
+ public async Task ResumeAsync_NoSessionId_RecoversNewestTranscriptAndPersistsIt()
+ {
+ var (listId, _) = await SeedListAsync();
+ var parent = await SeedManualTaskAsync(listId);
+ var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
+
+ WriteTranscript(startCtx.WorktreePath, "older-session", new DateTime(2026, 8, 1, 10, 0, 0, DateTimeKind.Utc));
+ WriteTranscript(startCtx.WorktreePath, "newest-session", new DateTime(2026, 8, 2, 10, 0, 0, DateTimeKind.Utc));
+
+ var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
+
+ Assert.Equal("newest-session", resumeCtx.ClaudeSessionId);
+ var reloaded = await _tasks.GetByIdAsync(parent.Id);
+ Assert.Equal("newest-session", reloaded!.PlanningSessionId);
+ }
+
+ // A transcript must never override an id we already captured.
+ [Fact]
+ public async Task ResumeAsync_ExistingSessionId_IgnoresTranscript()
+ {
+ var (listId, _) = await SeedListAsync();
+ var parent = await SeedManualTaskAsync(listId);
+ var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
+ await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "persisted-session");
+
+ WriteTranscript(startCtx.WorktreePath, "transcript-session", new DateTime(2026, 8, 2, 10, 0, 0, DateTimeKind.Utc));
+
+ var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
+
+ Assert.Equal("persisted-session", resumeCtx.ClaudeSessionId);
+ }
+
[Fact]
public async Task FinalizeAsync_PromotesDraftsAndMarksPlanningFinalized()
{