llar 0.1.1__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 (51) hide show
  1. llar/__init__.py +2 -0
  2. llar/__main__.py +7 -0
  3. llar/cli.py +646 -0
  4. llar/core/__init__.py +2 -0
  5. llar/core/activity.py +32 -0
  6. llar/core/associations.py +271 -0
  7. llar/core/atomic.py +38 -0
  8. llar/core/context.py +36 -0
  9. llar/core/conversations.py +93 -0
  10. llar/core/coverage.py +293 -0
  11. llar/core/doctor.py +72 -0
  12. llar/core/file_copy.py +431 -0
  13. llar/core/inspection.py +40 -0
  14. llar/core/inventory.py +41 -0
  15. llar/core/scope.py +29 -0
  16. llar/core/snapshot.py +94 -0
  17. llar/core/ssh.py +335 -0
  18. llar/core/ssh_receiver.py +161 -0
  19. llar/core/ssh_transfer.py +306 -0
  20. llar/core/state.py +185 -0
  21. llar/core/transfer_plan.py +725 -0
  22. llar/core/transfers.py +206 -0
  23. llar/harnesses/__init__.py +8 -0
  24. llar/harnesses/base.py +19 -0
  25. llar/harnesses/claude/__init__.py +121 -0
  26. llar/harnesses/claude/activity.py +80 -0
  27. llar/harnesses/claude/copy.py +577 -0
  28. llar/harnesses/claude/locking.py +136 -0
  29. llar/harnesses/claude/paths.py +18 -0
  30. llar/harnesses/claude/records.py +220 -0
  31. llar/harnesses/claude/state.py +385 -0
  32. llar/harnesses/claude/transfer.py +131 -0
  33. llar/harnesses/codex/Apache-2.0.txt +201 -0
  34. llar/harnesses/codex/NOTICE +6 -0
  35. llar/harnesses/codex/__init__.py +96 -0
  36. llar/harnesses/codex/activity.py +75 -0
  37. llar/harnesses/codex/copy.py +355 -0
  38. llar/harnesses/codex/database.py +155 -0
  39. llar/harnesses/codex/import_state.py +481 -0
  40. llar/harnesses/codex/native-schema.json +621 -0
  41. llar/harnesses/codex/publication.py +108 -0
  42. llar/harnesses/codex/schemas.json +501 -0
  43. llar/harnesses/codex/schemas.py +38 -0
  44. llar/harnesses/codex/state.py +519 -0
  45. llar/harnesses/codex/transfer.py +100 -0
  46. llar/harnesses/transfers.py +91 -0
  47. llar-0.1.1.dist-info/METADATA +311 -0
  48. llar-0.1.1.dist-info/RECORD +51 -0
  49. llar-0.1.1.dist-info/WHEEL +4 -0
  50. llar-0.1.1.dist-info/entry_points.txt +3 -0
  51. llar-0.1.1.dist-info/licenses/LICENSE +21 -0
llar/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # ABOUTME: Provides the llar package for managing coding agents' local state.
2
+ # ABOUTME: Keeps package imports free of filesystem access and CLI side effects.
llar/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # ABOUTME: Runs llar through Python's module entry point.
2
+ # ABOUTME: Delegates command handling to the installed CLI application.
3
+
4
+ from llar.cli import app
5
+
6
+ if __name__ == "__main__":
7
+ app()
llar/cli.py ADDED
@@ -0,0 +1,646 @@
1
+ # ABOUTME: Exposes project summaries, conversations, activity and diagnostics.
2
+ # ABOUTME: Applies explicit directory scopes to the appropriate evidence fields.
3
+
4
+ import json
5
+ import stat
6
+ from importlib.metadata import version
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from llar.core.context import Context
13
+ from llar.core.coverage import Report
14
+ from llar.core.file_copy import CopyResult
15
+ from llar.core.inventory import Project
16
+ from llar.core.scope import Scope
17
+ from llar.core.snapshot import Snapshot, collect
18
+ from llar.core.transfer_plan import TransferPlan, build_plan
19
+ from llar.core.transfers import Endpoint, TransferError
20
+ from llar.harnesses import HARNESSES
21
+ from llar.harnesses.base import Harness
22
+ from llar.harnesses.transfers import execute_application
23
+
24
+ app = typer.Typer(
25
+ help="Look after the local state coding agents keep about your projects.",
26
+ no_args_is_help=True,
27
+ add_completion=False,
28
+ )
29
+ projects_app = typer.Typer(
30
+ no_args_is_help=True, help="Inspect known project directories."
31
+ )
32
+ sessions_app = typer.Typer(no_args_is_help=True, help="Inspect logical conversations.")
33
+ activity_app = typer.Typer(no_args_is_help=True, help="Inspect runtime observations.")
34
+ trust_app = typer.Typer(no_args_is_help=True, help="Inspect explicit trust settings.")
35
+ app.add_typer(projects_app, name="projects")
36
+ app.add_typer(sessions_app, name="sessions")
37
+ app.add_typer(activity_app, name="activity")
38
+ projects_app.add_typer(trust_app, name="trust")
39
+
40
+ Home = Annotated[Path | None, typer.Option(help="Inspect an isolated alternate home.")]
41
+ Json = Annotated[bool, typer.Option("--json", help="Emit machine-readable output.")]
42
+ Selection = Annotated[
43
+ list[str] | None, typer.Option("--harness", help="Select a harness; repeatable.")
44
+ ]
45
+ Directory = Annotated[
46
+ Path | None, typer.Option("--dir", help="Exact associated directory.")
47
+ ]
48
+ Recursive = Annotated[bool, typer.Option(help="Include descendants of --dir.")]
49
+ Unassigned = Annotated[bool, typer.Option(help="Only objects with unknown ownership.")]
50
+ ProjectPath = Annotated[Path, typer.Argument(help="Exact project directory.")]
51
+
52
+
53
+ def selected(names: list[str] | None) -> list[Harness]:
54
+ if names is not None and set(names) - {harness.name for harness in HARNESSES}:
55
+ raise typer.BadParameter("Unknown harness. Choose claude or codex.")
56
+ return [harness for harness in HARNESSES if names is None or harness.name in names]
57
+
58
+
59
+ def inspect(home: Path | None, names: list[str] | None) -> Snapshot:
60
+ return collect(Context.create(home), selected(names))
61
+
62
+
63
+ def scope(
64
+ directory: Path | None, recursive: bool = False, unassigned: bool = False
65
+ ) -> Scope:
66
+ try:
67
+ return Scope(
68
+ directory.expanduser().resolve() if directory is not None else None,
69
+ recursive,
70
+ unassigned,
71
+ )
72
+ except ValueError as error:
73
+ raise typer.BadParameter(str(error)) from None
74
+
75
+
76
+ def emit(
77
+ rows: list[dict[str, object]],
78
+ json_: bool,
79
+ report: Report,
80
+ *,
81
+ empty: str = "No matching records in inspected sources.",
82
+ ) -> None:
83
+ if json_:
84
+ typer.echo(
85
+ json.dumps(
86
+ {
87
+ "schema_version": 1,
88
+ "items": rows,
89
+ "diagnostics": report.diagnostics,
90
+ "coverage": report.as_dict(),
91
+ }
92
+ )
93
+ )
94
+ else:
95
+ if not rows:
96
+ typer.echo(
97
+ empty
98
+ if report.complete
99
+ else "Inspection incomplete; no matching records in readable sources."
100
+ )
101
+ for row in rows:
102
+ if "explicit_trust" in row:
103
+ typer.echo(f"{row['harness']} {json.dumps(row['path'])}")
104
+ trust = row["explicit_trust"]
105
+ if trust is None:
106
+ trust = (
107
+ "unset" if row.get("explicit_trust_inspected") else "unknown"
108
+ )
109
+ typer.echo(f" explicit_trust={trust}")
110
+ if "session_count" in row:
111
+ typer.echo(
112
+ f" exists={row['exists']}, known={row['known']}, "
113
+ f"sessions={row['session_count']}, "
114
+ f"transcripts={row['transcript_count']}, "
115
+ f"activity_records={row['activity_record_count']}, "
116
+ f"live_sessions={row['live_session_count']}"
117
+ )
118
+ typer.echo(" memory_inventory=not_inspected")
119
+ paths = row.get("state_paths", [])
120
+ if isinstance(paths, list):
121
+ for path in paths:
122
+ typer.echo(f" state: {json.dumps(path)}")
123
+ typer.echo(f" config: {json.dumps(row['config_file'])}")
124
+ elif "ref" in row:
125
+ typer.echo(
126
+ f"{json.dumps(row['ref'])} "
127
+ f"started_in={json.dumps(row['started_in'])}"
128
+ )
129
+ typer.echo(
130
+ f" activity={row['activity']}, "
131
+ f"transcripts={row['transcript_count']}, "
132
+ f"activity_records={row['activity_record_count']}"
133
+ )
134
+ elif "session_id" in row:
135
+ typer.echo(
136
+ f"{row['harness']} {row['state']} {json.dumps(row['session_id'])} "
137
+ f"observed_in={json.dumps(row['observed_in'])}"
138
+ )
139
+ typer.echo(f" Evidence: {row['evidence']}")
140
+ elif "code" in row:
141
+ typer.echo(f"{row['harness']} {row['code']} {json.dumps(row['path'])}")
142
+ typer.echo(f" Evidence: {row['evidence']}")
143
+ typer.echo(f" Next: {row['suggestion']}")
144
+ else:
145
+ typer.echo(json.dumps(row, ensure_ascii=True))
146
+ typer.echo(
147
+ f"Coverage: {'complete' if report.complete else 'incomplete'} "
148
+ f"for inspected metadata; {len(report.limitations)} declared limitations."
149
+ )
150
+ for limitation in report.limitations:
151
+ typer.echo(f" Limit: {limitation['harness']}: {limitation['detail']}")
152
+ for diagnostic in report.diagnostics:
153
+ typer.echo(
154
+ f"{diagnostic['harness']} {diagnostic['code']}: "
155
+ f"{diagnostic['message']} {json.dumps(diagnostic['path'])}",
156
+ err=True,
157
+ )
158
+ if not report.complete:
159
+ raise typer.Exit(1)
160
+
161
+
162
+ def project_rows(
163
+ snapshot: Snapshot,
164
+ context: Context,
165
+ names: list[str] | None,
166
+ path: Path,
167
+ ) -> list[dict[str, object]]:
168
+ target = path.expanduser().resolve()
169
+ result: list[dict[str, object]] = []
170
+ for harness in selected(names):
171
+ matches = [
172
+ project
173
+ for project in snapshot.projects
174
+ if project.path == target and project.harness == harness.name
175
+ ]
176
+ if not matches:
177
+ item = Project(target, harness.name, harness.config_file(context))
178
+ item.explicit_trust_inspected = snapshot.report.inspected(
179
+ harness.name, "project_config"
180
+ )
181
+ try:
182
+ item.exists = stat.S_ISDIR(target.stat().st_mode)
183
+ except FileNotFoundError:
184
+ item.exists = False
185
+ except OSError:
186
+ snapshot.report.add(
187
+ harness.name,
188
+ "unreadable-project",
189
+ target,
190
+ "Cannot inspect the requested project directory.",
191
+ )
192
+ matches = [item]
193
+ result.extend(project.as_dict() for project in matches)
194
+ return result
195
+
196
+
197
+ def show_version(value: bool) -> None:
198
+ if value:
199
+ typer.echo(version("llar"))
200
+ raise typer.Exit()
201
+
202
+
203
+ @app.callback()
204
+ def main(
205
+ version_: Annotated[
206
+ bool, typer.Option("--version", callback=show_version, is_eager=True)
207
+ ] = False,
208
+ ) -> None:
209
+ """Inspect local coding-agent state."""
210
+
211
+
212
+ @app.command()
213
+ def harnesses(
214
+ home: Home = None, json_: Json = False, harness: Selection = None
215
+ ) -> None:
216
+ """Show detected harnesses and supported read capabilities."""
217
+ context = Context.create(home)
218
+ report = Report()
219
+ rows: list[dict[str, object]] = []
220
+ for item in selected(harness):
221
+ root = item.state_home(context)
222
+ config_path = item.config_file(context)
223
+ home_source = report.source(item.name, "home_discovery", root)
224
+ config_source = report.source(item.name, "config_discovery", config_path)
225
+ home_mode = home_source.mode(root, optional=True)
226
+ config_mode = config_source.mode(config_path, optional=True)
227
+ if home_mode is not None and not stat.S_ISDIR(home_mode):
228
+ home_source.problem(
229
+ "unsupported-home", root, "Harness home is not a directory."
230
+ )
231
+ if config_mode is not None and not stat.S_ISREG(config_mode):
232
+ config_source.problem(
233
+ "unsupported-config", config_path, "Config is not a regular file."
234
+ )
235
+ present = (home_mode is not None and stat.S_ISDIR(home_mode)) or (
236
+ config_mode is not None and stat.S_ISREG(config_mode)
237
+ )
238
+ unknown = home_source.status == "partial" or config_source.status == "partial"
239
+ rows.append(
240
+ {
241
+ "name": item.name,
242
+ "present": True if present else None if unknown else False,
243
+ "config_file": str(config_path),
244
+ "state_locations": [str(root), str(config_path)],
245
+ "capabilities": [
246
+ "projects",
247
+ "conversations",
248
+ "activity",
249
+ "diagnostics",
250
+ ],
251
+ }
252
+ )
253
+ emit(rows, json_, report)
254
+
255
+
256
+ @app.command()
257
+ def status(
258
+ path: ProjectPath = Path("."),
259
+ home: Home = None,
260
+ json_: Json = False,
261
+ harness: Selection = None,
262
+ ) -> None:
263
+ """Summarize one exact directory, including discovered storage locations."""
264
+ snapshot = inspect(home, harness)
265
+ emit(
266
+ project_rows(snapshot, Context.create(home), harness, path),
267
+ json_,
268
+ snapshot.report,
269
+ )
270
+
271
+
272
+ @projects_app.command("list")
273
+ def list_projects(
274
+ home: Home = None,
275
+ json_: Json = False,
276
+ harness: Selection = None,
277
+ directory: Directory = None,
278
+ recursive: Recursive = False,
279
+ missing: Annotated[bool, typer.Option(help="Only missing directories.")] = False,
280
+ ) -> None:
281
+ """List all recorded directories, optionally selecting an exact subtree."""
282
+ selection = scope(directory, recursive)
283
+ snapshot = inspect(home, harness)
284
+ emit(
285
+ [
286
+ row.as_dict()
287
+ for row in snapshot.projects
288
+ if selection.matches(row.path) and (not missing or row.exists is False)
289
+ ],
290
+ json_,
291
+ snapshot.report,
292
+ )
293
+
294
+
295
+ @trust_app.command("show")
296
+ def show_trust(
297
+ path: ProjectPath = Path("."),
298
+ home: Home = None,
299
+ json_: Json = False,
300
+ harness: Selection = None,
301
+ ) -> None:
302
+ """Show stored explicit trust, without inferring effective trust."""
303
+ snapshot = inspect(home, harness)
304
+ emit(
305
+ [
306
+ {
307
+ key: row[key]
308
+ for key in (
309
+ "path",
310
+ "harness",
311
+ "explicit_trust",
312
+ "explicit_trust_inspected",
313
+ "config_file",
314
+ )
315
+ }
316
+ for row in project_rows(snapshot, Context.create(home), harness, path)
317
+ ],
318
+ json_,
319
+ snapshot.report,
320
+ )
321
+
322
+
323
+ @sessions_app.command("list")
324
+ def list_sessions(
325
+ home: Home = None,
326
+ json_: Json = False,
327
+ harness: Selection = None,
328
+ directory: Directory = None,
329
+ recursive: Recursive = False,
330
+ unassigned: Unassigned = False,
331
+ live: Annotated[
332
+ bool, typer.Option(help="Only verified active conversations.")
333
+ ] = False,
334
+ ) -> None:
335
+ """List conversations by starting directory, never substituting runtime cwd."""
336
+ selection = scope(directory, recursive, unassigned)
337
+ snapshot = inspect(home, harness)
338
+ emit(
339
+ [
340
+ row.as_dict()
341
+ for row in snapshot.conversations
342
+ if selection.matches(row.started_in)
343
+ and (not live or row.activity_state == "live")
344
+ ],
345
+ json_,
346
+ snapshot.report,
347
+ )
348
+
349
+
350
+ @sessions_app.command("show")
351
+ def show_session(
352
+ ref: str, home: Home = None, json_: Json = False, harness: Selection = None
353
+ ) -> None:
354
+ """Show metadata for one harness-qualified or unambiguous native session ID."""
355
+ snapshot = inspect(home, harness)
356
+ matches = [
357
+ row for row in snapshot.conversations if row.ref == ref or row.session_id == ref
358
+ ]
359
+ if not matches and not snapshot.report.complete:
360
+ emit([], json_, snapshot.report)
361
+ if len(matches) != 1:
362
+ raise typer.BadParameter(
363
+ "Session reference is missing or ambiguous; use harness:ID."
364
+ )
365
+ emit([matches[0].as_dict()], json_, snapshot.report)
366
+
367
+
368
+ @activity_app.command("list")
369
+ def list_activity(
370
+ home: Home = None,
371
+ json_: Json = False,
372
+ harness: Selection = None,
373
+ directory: Directory = None,
374
+ recursive: Recursive = False,
375
+ unassigned: Unassigned = False,
376
+ state: Annotated[
377
+ str | None, typer.Option(help="Filter live, stale or unknown observations.")
378
+ ] = None,
379
+ ) -> None:
380
+ """List runtime observations by observed directory, including stale records."""
381
+ selection = scope(directory, recursive, unassigned)
382
+ if state is not None and state not in ("live", "stale", "unknown"):
383
+ raise typer.BadParameter("--state must be live, stale or unknown.")
384
+ snapshot = inspect(home, harness)
385
+ emit(
386
+ [
387
+ row.as_dict()
388
+ for row in snapshot.activity
389
+ if selection.matches(row.observed_in)
390
+ and (state is None or row.state == state)
391
+ ],
392
+ json_,
393
+ snapshot.report,
394
+ )
395
+
396
+
397
+ @app.command()
398
+ def doctor(
399
+ home: Home = None,
400
+ json_: Json = False,
401
+ harness: Selection = None,
402
+ directory: Directory = None,
403
+ recursive: Recursive = False,
404
+ unassigned: Unassigned = False,
405
+ ) -> None:
406
+ """Explain issues in inspected state without repairing anything."""
407
+ selection = scope(directory, recursive, unassigned)
408
+ snapshot = inspect(home, harness)
409
+ findings = snapshot.findings
410
+ emit(
411
+ [
412
+ row.as_dict()
413
+ for row in findings
414
+ if selection.matches(
415
+ None if row.code == "unassigned-state" else Path(row.path)
416
+ )
417
+ ],
418
+ json_,
419
+ snapshot.report,
420
+ empty="No issues found in inspected state.",
421
+ )
422
+
423
+
424
+ def emit_transfer(
425
+ plan: TransferPlan,
426
+ *,
427
+ apply: bool,
428
+ json_: bool,
429
+ result: CopyResult | None = None,
430
+ ) -> None:
431
+ mode = "apply" if apply else "dry-run"
432
+ row = {"mode": mode, **plan.as_dict()}
433
+ if result is not None:
434
+ row.update(result.as_dict())
435
+ if json_:
436
+ typer.echo(
437
+ json.dumps(
438
+ {
439
+ "schema_version": 1,
440
+ "items": [row],
441
+ "diagnostics": plan.diagnostics,
442
+ "coverage": plan.coverage(),
443
+ }
444
+ )
445
+ )
446
+ else:
447
+ typer.echo(f"{plan.operation} {mode}")
448
+ for label, address, root in (
449
+ ("Source", plan.source_endpoint, plan.source),
450
+ ("Destination", plan.destination_endpoint, plan.destination),
451
+ ):
452
+ value = str(root) if root is not None else f"{address.host}:{address.path}"
453
+ if address.host is not None and root is not None:
454
+ value = f"{address.host}:{root}"
455
+ typer.echo(f"{label}: {json.dumps(value)}")
456
+ typer.echo("Components: " + ", ".join(plan.components))
457
+ typer.echo(f"Proposed changes: {len(plan.changes)}")
458
+ for change in plan.changes:
459
+ target = str(change.destination) if change.destination is not None else None
460
+ typer.echo(
461
+ f" {change.effect} {change.harness or 'filesystem'} "
462
+ f"{change.component}: {json.dumps(str(change.source))} "
463
+ f"-> {json.dumps(target)}"
464
+ )
465
+ if change.source_key or change.destination_key:
466
+ typer.echo(
467
+ f" selector: {json.dumps(change.source_key)} "
468
+ f"-> {json.dumps(change.destination_key)}"
469
+ )
470
+ if change.destination_owners:
471
+ typer.echo(
472
+ " owners: "
473
+ + json.dumps([str(owner) for owner in change.destination_owners])
474
+ )
475
+ if change.started_in is not None:
476
+ typer.echo(
477
+ f" started_in (preserved): {json.dumps(str(change.started_in))}"
478
+ )
479
+ if change.cleanup == "after_verification":
480
+ typer.echo(" source cleanup only after verification")
481
+ for blocker in plan.blockers:
482
+ location = str(blocker.path) if blocker.path is not None else None
483
+ typer.echo(
484
+ f"BLOCKED {blocker.code} ({blocker.endpoint}): "
485
+ f"{json.dumps(blocker.message)} {json.dumps(location)}"
486
+ )
487
+ for diagnostic in plan.diagnostics:
488
+ if diagnostic.get("incomplete") is True:
489
+ typer.echo(
490
+ f" {diagnostic['endpoint']} {diagnostic['code']}: "
491
+ f"{json.dumps(diagnostic['message'])} "
492
+ f"{json.dumps(diagnostic['path'])}"
493
+ )
494
+ typer.echo(
495
+ "Inspection coverage: "
496
+ + ("complete" if plan.coverage()["complete"] else "incomplete")
497
+ + " for declared metadata. Transfer support is incomplete."
498
+ )
499
+ if result is not None and result.applied:
500
+ typer.echo("Copy completed and verified. Source retained.")
501
+ elif result is not None and result.error is not None:
502
+ typer.echo(f"Apply failed: {result.error}. Source retained.")
503
+ publication = (
504
+ "unknown"
505
+ if row.get("publication_unknown")
506
+ else result.destination_published
507
+ )
508
+ typer.echo(f"Destination published: {publication}")
509
+ else:
510
+ typer.echo("No changes made.")
511
+ if plan.can_apply:
512
+ typer.echo("Use --apply to perform this copy.")
513
+ native = row.get("published_native")
514
+ if isinstance(native, list):
515
+ for path in native:
516
+ typer.echo("Published native state: " + json.dumps(path))
517
+ if result is not None and result.record is not None:
518
+ host = row.get("operation_host")
519
+ receipt = f"{host}:{result.record}" if host else str(result.record)
520
+ typer.echo("Operation record: " + json.dumps(receipt))
521
+ if result is not None and result.staging is not None:
522
+ host = row.get("operation_host")
523
+ stage = f"{host}:{result.staging}" if host else str(result.staging)
524
+ typer.echo("Staging retained: " + json.dumps(stage))
525
+ if (
526
+ plan.blockers
527
+ or not plan.coverage()["complete"]
528
+ or result is not None
529
+ and result.error is not None
530
+ ):
531
+ raise typer.Exit(1)
532
+
533
+
534
+ @app.command("mv", help="Plan a project move; application is not implemented.")
535
+ @app.command("cp", help="Plan a copy; apply files or supported local native state.")
536
+ def transfer(
537
+ context: typer.Context,
538
+ source: Annotated[
539
+ str, typer.Argument(help="Explicit local or SSH source directory.")
540
+ ],
541
+ destination: Annotated[
542
+ str, typer.Argument(help="Exact resulting local or SSH project directory.")
543
+ ],
544
+ home: Home = None,
545
+ target_home: Annotated[
546
+ Path | None,
547
+ typer.Option(
548
+ "--target-home",
549
+ help="Isolated destination home; defaults to --home for local transfers.",
550
+ ),
551
+ ] = None,
552
+ ssh_config: Annotated[
553
+ Path | None,
554
+ typer.Option(
555
+ "--ssh-config", help="OpenSSH configuration file for this transfer."
556
+ ),
557
+ ] = None,
558
+ only: Annotated[
559
+ list[str] | None,
560
+ typer.Option(
561
+ "--only",
562
+ help="Select files, sessions, memory, settings or artifacts; repeatable.",
563
+ ),
564
+ ] = None,
565
+ harness: Selection = None,
566
+ dry_run: Annotated[
567
+ bool, typer.Option("--dry-run", help="Show the transfer plan (default).")
568
+ ] = False,
569
+ apply_: Annotated[
570
+ bool,
571
+ typer.Option("--apply", help="Apply a supported transfer after preflight."),
572
+ ] = False,
573
+ json_: Json = False,
574
+ ) -> None:
575
+ if dry_run and apply_:
576
+ raise typer.BadParameter("--apply and --dry-run are mutually exclusive.")
577
+ names = tuple(item.name for item in selected(harness))
578
+ try:
579
+ origin = Endpoint.parse(source)
580
+ target = Endpoint.parse(destination)
581
+ if target_home is None and origin.host is None and target.host is None:
582
+ target_home = home
583
+ if origin.host is not None or target.host is not None:
584
+ from llar.core.ssh_transfer import execute, plan_transfer
585
+
586
+ plan, connection = plan_transfer(
587
+ "mv" if context.info_name == "mv" else "cp",
588
+ origin,
589
+ target,
590
+ home=home,
591
+ target_home=target_home,
592
+ only=set(only) if only is not None else None,
593
+ harnesses=names,
594
+ ssh_config=ssh_config,
595
+ )
596
+ remote_result = execute(plan, connection, apply=True) if apply_ else None
597
+ emit_transfer(plan, apply=apply_, json_=json_, result=remote_result)
598
+ return
599
+ if ssh_config is not None:
600
+ raise typer.BadParameter("--ssh-config requires one SSH endpoint.")
601
+ plan = build_plan(
602
+ "mv" if context.info_name == "mv" else "cp",
603
+ origin,
604
+ target,
605
+ Context.create(home),
606
+ Context.create(target_home),
607
+ only=set(only) if only is not None else None,
608
+ harnesses=names,
609
+ )
610
+ except typer.Exit:
611
+ raise
612
+ except TransferError as error:
613
+ raise typer.BadParameter(str(error)) from None
614
+ except (OSError, RuntimeError):
615
+ raise typer.BadParameter(
616
+ "Cannot resolve the requested filesystem context."
617
+ ) from None
618
+ result = (
619
+ execute_application(
620
+ plan,
621
+ Context.create(home),
622
+ Context.create(target_home),
623
+ apply=True,
624
+ )
625
+ if apply_ and plan.can_apply
626
+ else None
627
+ )
628
+ emit_transfer(plan, apply=apply_, json_=json_, result=result)
629
+
630
+
631
+ @app.command("_remote", hidden=True)
632
+ def remote_peer() -> None:
633
+ from llar.core.ssh import serve
634
+
635
+ serve()
636
+
637
+
638
+ @app.command(
639
+ "_rsync",
640
+ hidden=True,
641
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
642
+ )
643
+ def remote_rsync(context: typer.Context) -> None:
644
+ from llar.core.ssh import serve_rsync
645
+
646
+ serve_rsync(context.args)
llar/core/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # ABOUTME: Holds shared domain primitives for local agent state.
2
+ # ABOUTME: Keeps harness-independent data separate from command rendering.
llar/core/activity.py ADDED
@@ -0,0 +1,32 @@
1
+ # ABOUTME: Represents live, stale and unverifiable runtime ownership observations.
2
+ # ABOUTME: Keeps process and writer-lock evidence separate from transcript counts.
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ ActivityState = Literal["live", "stale", "unknown"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Activity:
13
+ harness: str
14
+ session_id: str | None
15
+ observed_in: Path | None
16
+ source: Path
17
+ state: ActivityState
18
+ evidence: str
19
+ pid: int | None = None
20
+
21
+ def as_dict(self) -> dict[str, object]:
22
+ return {
23
+ "harness": self.harness,
24
+ "session_id": self.session_id,
25
+ "observed_in": str(self.observed_in)
26
+ if self.observed_in is not None
27
+ else None,
28
+ "source": str(self.source),
29
+ "state": self.state,
30
+ "evidence": self.evidence,
31
+ "pid": self.pid,
32
+ }