taskledger 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. taskledger/__init__.py +5 -0
  2. taskledger/__main__.py +6 -0
  3. taskledger/_version.py +24 -0
  4. taskledger/api/__init__.py +13 -0
  5. taskledger/api/handoff.py +247 -0
  6. taskledger/api/introductions.py +9 -0
  7. taskledger/api/locks.py +4 -0
  8. taskledger/api/plans.py +31 -0
  9. taskledger/api/project.py +185 -0
  10. taskledger/api/questions.py +19 -0
  11. taskledger/api/search.py +87 -0
  12. taskledger/api/task_runs.py +38 -0
  13. taskledger/api/tasks.py +61 -0
  14. taskledger/cli.py +600 -0
  15. taskledger/cli_actor.py +196 -0
  16. taskledger/cli_common.py +617 -0
  17. taskledger/cli_implement.py +409 -0
  18. taskledger/cli_migrate.py +328 -0
  19. taskledger/cli_misc.py +984 -0
  20. taskledger/cli_plan.py +478 -0
  21. taskledger/cli_question.py +350 -0
  22. taskledger/cli_task.py +257 -0
  23. taskledger/cli_validate.py +285 -0
  24. taskledger/command_inventory.py +125 -0
  25. taskledger/domain/__init__.py +2 -0
  26. taskledger/domain/models.py +1697 -0
  27. taskledger/domain/policies.py +542 -0
  28. taskledger/domain/states.py +320 -0
  29. taskledger/errors.py +165 -0
  30. taskledger/exchange.py +343 -0
  31. taskledger/ids.py +19 -0
  32. taskledger/py.typed +0 -0
  33. taskledger/search.py +349 -0
  34. taskledger/services/__init__.py +1 -0
  35. taskledger/services/actors.py +245 -0
  36. taskledger/services/dashboard.py +306 -0
  37. taskledger/services/doctor.py +435 -0
  38. taskledger/services/handoff.py +1029 -0
  39. taskledger/services/handoff_lifecycle.py +154 -0
  40. taskledger/services/navigation.py +930 -0
  41. taskledger/services/phase5_lock_transfer.py +96 -0
  42. taskledger/services/plan_lint.py +397 -0
  43. taskledger/services/serve_read_model.py +852 -0
  44. taskledger/services/tasks.py +4224 -0
  45. taskledger/services/validation.py +221 -0
  46. taskledger/services/web_dashboard.py +1742 -0
  47. taskledger/storage/__init__.py +39 -0
  48. taskledger/storage/atomic.py +57 -0
  49. taskledger/storage/common.py +90 -0
  50. taskledger/storage/events.py +98 -0
  51. taskledger/storage/frontmatter.py +57 -0
  52. taskledger/storage/indexes.py +42 -0
  53. taskledger/storage/init.py +187 -0
  54. taskledger/storage/locks.py +83 -0
  55. taskledger/storage/meta.py +103 -0
  56. taskledger/storage/migrations.py +207 -0
  57. taskledger/storage/paths.py +166 -0
  58. taskledger/storage/project_config.py +393 -0
  59. taskledger/storage/repos.py +256 -0
  60. taskledger/storage/task_store.py +836 -0
  61. taskledger/timeutils.py +7 -0
  62. taskledger-0.1.0.dist-info/METADATA +411 -0
  63. taskledger-0.1.0.dist-info/RECORD +67 -0
  64. taskledger-0.1.0.dist-info/WHEEL +5 -0
  65. taskledger-0.1.0.dist-info/entry_points.txt +2 -0
  66. taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
  67. taskledger-0.1.0.dist-info/top_level.txt +1 -0
taskledger/search.py ADDED
@@ -0,0 +1,349 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import os
5
+ import re
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from taskledger.errors import LaunchError
12
+ from taskledger.storage.paths import ProjectPaths
13
+ from taskledger.storage.repos import (
14
+ ProjectRepo,
15
+ load_repos,
16
+ resolve_repo,
17
+ resolve_repo_root,
18
+ )
19
+
20
+ SearchMatchKind = Literal["path", "content", "symbol"]
21
+
22
+ _TEXT_EXTENSIONS = {
23
+ ".csv",
24
+ ".html",
25
+ ".js",
26
+ ".json",
27
+ ".md",
28
+ ".po",
29
+ ".py",
30
+ ".rst",
31
+ ".scss",
32
+ ".sql",
33
+ ".toml",
34
+ ".ts",
35
+ ".txt",
36
+ ".xml",
37
+ ".yaml",
38
+ ".yml",
39
+ }
40
+ _SKIP_DIRS = {".git", ".hg", ".svn", ".venv", "__pycache__", "node_modules"}
41
+ _SYMBOL_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
42
+ (
43
+ "python",
44
+ re.compile(r"^\s*(?:def|class)\s+([A-Za-z_][A-Za-z0-9_]*)", re.MULTILINE),
45
+ ),
46
+ (
47
+ "javascript",
48
+ re.compile(
49
+ r"^\s*(?:function|class)\s+([A-Za-z_$][A-Za-z0-9_$]*)", re.MULTILINE
50
+ ),
51
+ ),
52
+ ("xml-id", re.compile(r"""\bid=["']([^"']+)["']""")),
53
+ )
54
+
55
+
56
+ @dataclass(slots=True, frozen=True)
57
+ class ProjectSearchMatch:
58
+ repo: str
59
+ path: str
60
+ kind: SearchMatchKind
61
+ line: int | None
62
+ text: str
63
+ symbol: str | None = None
64
+
65
+ def to_dict(self) -> dict[str, object]:
66
+ return {
67
+ "repo": self.repo,
68
+ "path": self.path,
69
+ "kind": self.kind,
70
+ "line": self.line,
71
+ "text": self.text,
72
+ "symbol": self.symbol,
73
+ }
74
+
75
+
76
+ @dataclass(slots=True, frozen=True)
77
+ class ProjectDependencyInfo:
78
+ repo: str
79
+ module: str
80
+ manifest_path: str
81
+ depends: tuple[str, ...]
82
+ module_name: str | None
83
+
84
+ def to_dict(self) -> dict[str, object]:
85
+ return {
86
+ "repo": self.repo,
87
+ "module": self.module,
88
+ "manifest_path": self.manifest_path,
89
+ "depends": list(self.depends),
90
+ "module_name": self.module_name,
91
+ }
92
+
93
+
94
+ def search_project(
95
+ paths: ProjectPaths,
96
+ *,
97
+ query: str,
98
+ repo_refs: tuple[str, ...] = (),
99
+ limit: int = 50,
100
+ ) -> list[ProjectSearchMatch]:
101
+ lowered_query = query.strip().lower()
102
+ if not lowered_query:
103
+ raise LaunchError("Project search query must not be empty.")
104
+ matches: list[ProjectSearchMatch] = []
105
+ for repo, file_path, relative_path in iter_repo_files(paths, repo_refs=repo_refs):
106
+ path_text = relative_path.as_posix()
107
+ if lowered_query in path_text.lower():
108
+ matches.append(
109
+ ProjectSearchMatch(
110
+ repo=repo.name,
111
+ path=path_text,
112
+ kind="path",
113
+ line=None,
114
+ text=path_text,
115
+ )
116
+ )
117
+ if len(matches) >= limit:
118
+ break
119
+ content = _read_text_file(file_path)
120
+ if content is None:
121
+ continue
122
+ content_matches = 0
123
+ for index, line in enumerate(content.splitlines(), start=1):
124
+ if lowered_query not in line.lower():
125
+ continue
126
+ matches.append(
127
+ ProjectSearchMatch(
128
+ repo=repo.name,
129
+ path=path_text,
130
+ kind="content",
131
+ line=index,
132
+ text=line.strip(),
133
+ )
134
+ )
135
+ content_matches += 1
136
+ if len(matches) >= limit or content_matches >= 3:
137
+ break
138
+ if len(matches) >= limit:
139
+ break
140
+ return matches
141
+
142
+
143
+ def grep_project(
144
+ paths: ProjectPaths,
145
+ *,
146
+ pattern: str,
147
+ repo_refs: tuple[str, ...] = (),
148
+ limit: int = 100,
149
+ ) -> list[ProjectSearchMatch]:
150
+ if not pattern.strip():
151
+ raise LaunchError("Project grep pattern must not be empty.")
152
+ try:
153
+ compiled = re.compile(pattern)
154
+ except re.error as exc:
155
+ raise LaunchError(f"Invalid project grep pattern: {exc}") from exc
156
+ matches: list[ProjectSearchMatch] = []
157
+ for repo, file_path, relative_path in iter_repo_files(paths, repo_refs=repo_refs):
158
+ content = _read_text_file(file_path)
159
+ if content is None:
160
+ continue
161
+ for index, line in enumerate(content.splitlines(), start=1):
162
+ if compiled.search(line) is None:
163
+ continue
164
+ matches.append(
165
+ ProjectSearchMatch(
166
+ repo=repo.name,
167
+ path=relative_path.as_posix(),
168
+ kind="content",
169
+ line=index,
170
+ text=line.strip(),
171
+ )
172
+ )
173
+ if len(matches) >= limit:
174
+ return matches
175
+ return matches
176
+
177
+
178
+ def symbols_project(
179
+ paths: ProjectPaths,
180
+ *,
181
+ query: str,
182
+ repo_refs: tuple[str, ...] = (),
183
+ limit: int = 50,
184
+ ) -> list[ProjectSearchMatch]:
185
+ lowered_query = query.strip().lower()
186
+ if not lowered_query:
187
+ raise LaunchError("Project symbol query must not be empty.")
188
+ matches: list[ProjectSearchMatch] = []
189
+ for repo, file_path, relative_path in iter_repo_files(paths, repo_refs=repo_refs):
190
+ content = _read_text_file(file_path)
191
+ if content is None:
192
+ continue
193
+ for _, pattern in _SYMBOL_PATTERNS:
194
+ for match in pattern.finditer(content):
195
+ symbol = match.group(1)
196
+ if lowered_query not in symbol.lower():
197
+ continue
198
+ line = content.count("\n", 0, match.start()) + 1
199
+ matches.append(
200
+ ProjectSearchMatch(
201
+ repo=repo.name,
202
+ path=relative_path.as_posix(),
203
+ kind="symbol",
204
+ line=line,
205
+ text=symbol,
206
+ symbol=symbol,
207
+ )
208
+ )
209
+ if len(matches) >= limit:
210
+ return matches
211
+ return matches
212
+
213
+
214
+ def module_dependencies(
215
+ paths: ProjectPaths,
216
+ *,
217
+ repo_ref: str,
218
+ module: str,
219
+ ) -> ProjectDependencyInfo:
220
+ repo = resolve_repo(paths, repo_ref)
221
+ repo_root = resolve_repo_root(paths, repo.name)
222
+ manifest_path = _find_module_manifest(repo_root, module)
223
+ if manifest_path is None:
224
+ raise LaunchError(f"Could not find module '{module}' in repo {repo.name}.")
225
+ try:
226
+ payload = ast.literal_eval(manifest_path.read_text(encoding="utf-8"))
227
+ except (OSError, SyntaxError, ValueError) as exc:
228
+ raise LaunchError(f"Failed to parse manifest {manifest_path}: {exc}") from exc
229
+ if not isinstance(payload, dict):
230
+ raise LaunchError(f"Manifest {manifest_path} must contain a dict literal.")
231
+ depends = payload.get("depends", [])
232
+ if not isinstance(depends, list) or not all(
233
+ isinstance(item, str) for item in depends
234
+ ):
235
+ raise LaunchError(f"Manifest {manifest_path} has an invalid depends list.")
236
+ module_name = payload.get("name")
237
+ if module_name is not None and not isinstance(module_name, str):
238
+ raise LaunchError(f"Manifest {manifest_path} has an invalid module name.")
239
+ return ProjectDependencyInfo(
240
+ repo=repo.name,
241
+ module=module,
242
+ manifest_path=str(manifest_path.relative_to(repo_root)),
243
+ depends=tuple(depends),
244
+ module_name=module_name,
245
+ )
246
+
247
+
248
+ def iter_repo_files(
249
+ paths: ProjectPaths, *, repo_refs: tuple[str, ...] = ()
250
+ ) -> Iterable[tuple[ProjectRepo, Path, Path]]:
251
+ repos = _selected_repos(paths, repo_refs)
252
+ for repo in repos:
253
+ repo_root = resolve_repo_root(paths, repo.name)
254
+ for root, dirnames, filenames in os.walk(repo_root):
255
+ dirnames[:] = [
256
+ item
257
+ for item in dirnames
258
+ if item not in _SKIP_DIRS and not item.startswith(".")
259
+ ]
260
+ for filename in filenames:
261
+ file_path = Path(root) / filename
262
+ if file_path.suffix and file_path.suffix not in _TEXT_EXTENSIONS:
263
+ continue
264
+ yield repo, file_path, file_path.relative_to(repo_root)
265
+
266
+
267
+ def discover_relevant_files(
268
+ paths: ProjectPaths,
269
+ *,
270
+ query: str,
271
+ repo_refs: tuple[str, ...] = (),
272
+ limit: int = 12,
273
+ ) -> tuple[str, ...]:
274
+ tokens = _discovery_tokens(query)
275
+ if not tokens:
276
+ raise LaunchError("Project discovery query must contain useful search terms.")
277
+ scored: list[tuple[int, str]] = []
278
+ for repo, file_path, relative_path in iter_repo_files(paths, repo_refs=repo_refs):
279
+ path_text = relative_path.as_posix().lower()
280
+ content = _read_text_file(file_path)
281
+ lowered_content = content.lower() if content is not None else ""
282
+ score = 0
283
+ for token in tokens:
284
+ if token in path_text:
285
+ score += 4
286
+ if lowered_content and token in lowered_content:
287
+ score += 1
288
+ if relative_path.name == "__manifest__.py":
289
+ score += 1
290
+ if score <= 0:
291
+ continue
292
+ scored.append((score, f"{repo.name}:{relative_path.as_posix()}"))
293
+ scored.sort(key=lambda item: (-item[0], item[1]))
294
+ return tuple(item[1] for item in scored[:limit])
295
+
296
+
297
+ def _selected_repos(
298
+ paths: ProjectPaths, repo_refs: tuple[str, ...]
299
+ ) -> list[ProjectRepo]:
300
+ if repo_refs:
301
+ return [resolve_repo(paths, ref) for ref in repo_refs]
302
+ repos = load_repos(paths)
303
+ if not repos:
304
+ raise LaunchError("No project repos are registered.")
305
+ return repos
306
+
307
+
308
+ def _read_text_file(path: Path) -> str | None:
309
+ try:
310
+ return path.read_text(encoding="utf-8")
311
+ except UnicodeDecodeError:
312
+ return None
313
+ except OSError as exc:
314
+ raise LaunchError(f"Failed to read {path}: {exc}") from exc
315
+
316
+
317
+ def _find_module_manifest(repo_root: Path, module: str) -> Path | None:
318
+ for root, dirnames, filenames in os.walk(repo_root):
319
+ dirnames[:] = [
320
+ item
321
+ for item in dirnames
322
+ if item not in _SKIP_DIRS and not item.startswith(".")
323
+ ]
324
+ if "__manifest__.py" not in filenames:
325
+ continue
326
+ candidate = Path(root)
327
+ if candidate.name == module:
328
+ return candidate / "__manifest__.py"
329
+ return None
330
+
331
+
332
+ def _discovery_tokens(query: str) -> tuple[str, ...]:
333
+ stop_words = {
334
+ "and",
335
+ "for",
336
+ "from",
337
+ "have",
338
+ "into",
339
+ "that",
340
+ "the",
341
+ "this",
342
+ "with",
343
+ }
344
+ tokens: list[str] = []
345
+ for token in re.findall(r"[a-zA-Z0-9_]+", query.lower()):
346
+ if len(token) < 4 or token in stop_words or token in tokens:
347
+ continue
348
+ tokens.append(token)
349
+ return tuple(tokens[:16])
@@ -0,0 +1 @@
1
+ """State-oriented taskledger services."""
@@ -0,0 +1,245 @@
1
+ """Actor and harness identity resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import os
7
+ import socket
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from taskledger.domain.models import ActorRef, HarnessRef
12
+ from taskledger.domain.states import (
13
+ normalize_actor_role,
14
+ normalize_actor_type,
15
+ normalize_harness_kind,
16
+ )
17
+ from taskledger.ids import next_project_id
18
+ from taskledger.storage.task_store import load_actor_state, load_harness_state
19
+
20
+
21
+ def resolve_actor(
22
+ *,
23
+ actor_type: str | None = None,
24
+ actor_name: str | None = None,
25
+ role: str | None = None,
26
+ tool: str | None = None,
27
+ session_id: str | None = None,
28
+ harness_id: str | None = None,
29
+ workspace_root: Path | None = None,
30
+ ) -> ActorRef:
31
+ """
32
+ Resolve actor identity with fallback order:
33
+ 1. Explicit parameters
34
+ 2. Environment variables
35
+ 3. Stored state (actor.yaml)
36
+ 4. Auto-detect from environment
37
+ 5. Safe default
38
+ """
39
+
40
+ # 1. Use explicit params if provided
41
+ if actor_type and actor_name:
42
+ return ActorRef(
43
+ actor_type=normalize_actor_type(actor_type),
44
+ actor_name=actor_name,
45
+ role=normalize_actor_role(role) if role else None,
46
+ tool=tool,
47
+ session_id=session_id,
48
+ harness_id=harness_id,
49
+ host=socket.gethostname(),
50
+ pid=os.getpid(),
51
+ )
52
+
53
+ # 2. Check environment variables
54
+ env_actor_type = os.getenv("TASKLEDGER_ACTOR_TYPE")
55
+ env_actor_name = os.getenv("TASKLEDGER_ACTOR_NAME")
56
+ env_role = os.getenv("TASKLEDGER_ACTOR_ROLE")
57
+ env_harness = os.getenv("TASKLEDGER_HARNESS")
58
+ env_session_id = os.getenv("TASKLEDGER_SESSION_ID")
59
+ resolved_role = env_role or role
60
+
61
+ if env_actor_type or env_actor_name:
62
+ return ActorRef(
63
+ actor_type=normalize_actor_type(env_actor_type or actor_type or "agent"),
64
+ actor_name=env_actor_name or actor_name or "taskledger",
65
+ role=normalize_actor_role(resolved_role) if resolved_role else None,
66
+ tool=tool,
67
+ session_id=env_session_id or session_id,
68
+ harness_id=harness_id or env_harness,
69
+ host=socket.gethostname(),
70
+ pid=os.getpid(),
71
+ )
72
+
73
+ # 3. Check stored state
74
+ if workspace_root is not None:
75
+ stored = load_actor_state(workspace_root)
76
+ if stored is not None:
77
+ return ActorRef(
78
+ actor_type=stored.actor_type,
79
+ actor_name=stored.actor_name,
80
+ role=stored.role,
81
+ tool=stored.tool or tool,
82
+ session_id=stored.session_id or session_id,
83
+ harness_id=harness_id,
84
+ host=socket.gethostname(),
85
+ pid=os.getpid(),
86
+ )
87
+
88
+ # 4. Auto-detect from environment
89
+ if os.getenv("OPENCODE_VERSION"):
90
+ return ActorRef(
91
+ actor_type="agent",
92
+ actor_name="opencode",
93
+ tool="opencode",
94
+ session_id=session_id,
95
+ host=socket.gethostname(),
96
+ pid=os.getpid(),
97
+ )
98
+
99
+ if os.getenv("CODEX_VERSION"):
100
+ return ActorRef(
101
+ actor_type="agent",
102
+ actor_name="codex",
103
+ tool="codex",
104
+ session_id=session_id,
105
+ host=socket.gethostname(),
106
+ pid=os.getpid(),
107
+ )
108
+
109
+ if os.getenv("PI_VERSION"):
110
+ return ActorRef(
111
+ actor_type="agent",
112
+ actor_name="pi",
113
+ tool="pi",
114
+ session_id=session_id,
115
+ host=socket.gethostname(),
116
+ pid=os.getpid(),
117
+ )
118
+
119
+ if os.getenv("GITHUB_ACTIONS") == "true":
120
+ return ActorRef(
121
+ actor_type="system",
122
+ actor_name="github-actions",
123
+ tool="github-actions",
124
+ session_id=session_id or os.getenv("GITHUB_RUN_ID"),
125
+ host=socket.gethostname(),
126
+ pid=os.getpid(),
127
+ )
128
+
129
+ if sys.stdin.isatty() and not any(
130
+ [
131
+ os.getenv("OPENCODE_VERSION"),
132
+ os.getenv("CODEX_VERSION"),
133
+ os.getenv("PI_VERSION"),
134
+ os.getenv("GITHUB_ACTIONS"),
135
+ ]
136
+ ):
137
+ return ActorRef(
138
+ actor_type="user",
139
+ actor_name=getpass.getuser() or "user",
140
+ session_id=session_id,
141
+ host=socket.gethostname(),
142
+ pid=os.getpid(),
143
+ )
144
+
145
+ # 5. Safe default
146
+ return ActorRef(
147
+ actor_type="agent",
148
+ actor_name="taskledger",
149
+ tool=tool,
150
+ session_id=session_id,
151
+ host=socket.gethostname(),
152
+ pid=os.getpid(),
153
+ )
154
+
155
+
156
+ def resolve_harness(
157
+ *,
158
+ name: str | None = None,
159
+ kind: str | None = None,
160
+ session_id: str | None = None,
161
+ cwd: Path | None = None,
162
+ workspace_root: Path | None = None,
163
+ ) -> HarnessRef:
164
+ """
165
+ Resolve harness identity with fallback order:
166
+ 1. Explicit parameters
167
+ 2. Environment variables
168
+ 3. Stored state (harness.yaml)
169
+ 4. Auto-detect from environment
170
+ 5. Safe default
171
+ """
172
+
173
+ # Use explicit params if provided
174
+ if name:
175
+ harness_id = next_project_id("harness", [])
176
+ return HarnessRef(
177
+ harness_id=harness_id,
178
+ name=name,
179
+ kind=normalize_harness_kind(kind or "unknown"),
180
+ session_id=session_id,
181
+ working_directory=str(cwd) if cwd else None,
182
+ )
183
+
184
+ # Check environment
185
+ env_harness = os.getenv("TASKLEDGER_HARNESS")
186
+ if env_harness:
187
+ harness_id = next_project_id("harness", [])
188
+ return HarnessRef(
189
+ harness_id=harness_id,
190
+ name=env_harness,
191
+ kind=normalize_harness_kind(kind or "unknown"),
192
+ session_id=session_id or os.getenv("TASKLEDGER_SESSION_ID"),
193
+ working_directory=str(cwd) if cwd else None,
194
+ )
195
+
196
+ # Check stored state
197
+ if workspace_root is not None:
198
+ stored = load_harness_state(workspace_root)
199
+ if stored is not None:
200
+ harness_id = next_project_id("harness", [])
201
+ return HarnessRef(
202
+ harness_id=harness_id,
203
+ name=stored.name,
204
+ kind=stored.kind,
205
+ session_id=stored.session_id or session_id,
206
+ working_directory=str(cwd) if cwd else None,
207
+ )
208
+
209
+ # Auto-detect
210
+ if os.getenv("OPENCODE_VERSION"):
211
+ harness_id = next_project_id("harness", [])
212
+ return HarnessRef(
213
+ harness_id=harness_id,
214
+ name="opencode",
215
+ kind="agent_harness",
216
+ session_id=session_id,
217
+ )
218
+
219
+ if os.getenv("CODEX_VERSION"):
220
+ harness_id = next_project_id("harness", [])
221
+ return HarnessRef(
222
+ harness_id=harness_id,
223
+ name="codex",
224
+ kind="agent_harness",
225
+ session_id=session_id,
226
+ )
227
+
228
+ if os.getenv("GITHUB_ACTIONS") == "true":
229
+ harness_id = next_project_id("harness", [])
230
+ return HarnessRef(
231
+ harness_id=harness_id,
232
+ name="github-actions",
233
+ kind="ci",
234
+ session_id=session_id or os.getenv("GITHUB_RUN_ID"),
235
+ )
236
+
237
+ # Default
238
+ harness_id = next_project_id("harness", [])
239
+ return HarnessRef(
240
+ harness_id=harness_id,
241
+ name="unknown",
242
+ kind="unknown",
243
+ session_id=session_id,
244
+ working_directory=str(cwd) if cwd else None,
245
+ )