agentmarshal 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.
@@ -0,0 +1,3 @@
1
+ """AgentMarshal: agent work attestation and merge governance."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Module entry point for ``python -m agentmarshal``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentmarshal.cli import main
6
+
7
+ raise SystemExit(main())
agentmarshal/cli.py ADDED
@@ -0,0 +1,521 @@
1
+ """Command-line interface for AgentMarshal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+ from typing import TextIO, cast
11
+
12
+ from agentmarshal import __version__
13
+ from agentmarshal.doctor import run_doctor
14
+ from agentmarshal.journal.complete import (
15
+ LifecycleError,
16
+ abandon_task,
17
+ complete_task,
18
+ )
19
+ from agentmarshal.journal.gate import GateError, run_gate
20
+ from agentmarshal.journal.gate_context import derive_gate_context
21
+ from agentmarshal.journal.open_task import TaskOpenError, open_task
22
+ from agentmarshal.journal.report import ReportError, build_report, format_report
23
+ from agentmarshal.journal.review import ReviewLaunchError, launch_review
24
+ from agentmarshal.journal.session import SessionRecordError, record_session
25
+ from agentmarshal.journal.status import (
26
+ TaskStatus,
27
+ TaskStatusError,
28
+ list_task_statuses,
29
+ load_task_status,
30
+ )
31
+ from agentmarshal.journal.submit_review import ReviewSubmitError, submit_review
32
+ from agentmarshal.journal.validate import validate_journal
33
+ from agentmarshal.migrate import JournalMigrationError, migrate_journal
34
+ from agentmarshal.project import (
35
+ AgentMarshalProjectError,
36
+ AlreadyInitializedError,
37
+ find_project_root,
38
+ initialize_project,
39
+ )
40
+
41
+
42
+ def _build_parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(prog="agentmarshal")
44
+ parser.add_argument(
45
+ "--version",
46
+ action="version",
47
+ version=__version__,
48
+ )
49
+ subparsers = parser.add_subparsers(dest="command", required=True)
50
+ subparsers.add_parser("init", help="initialize AgentMarshal project metadata")
51
+ subparsers.add_parser("doctor", help="check AgentMarshal project health")
52
+ subparsers.add_parser("validate", help="validate the whole journal for integrity")
53
+ open_parser = subparsers.add_parser("open", help="open a journal task")
54
+ open_parser.add_argument("--title", required=True, help="task title")
55
+ open_parser.add_argument(
56
+ "--scope",
57
+ action="append",
58
+ default=[],
59
+ help="path included in the task scope (repeatable)",
60
+ )
61
+ status_parser = subparsers.add_parser("status", help="show journal task status")
62
+ status_parser.add_argument("task_id", nargs="?", help="task identifier")
63
+ review_parser = subparsers.add_parser(
64
+ "submit-review", help="record a task review verdict"
65
+ )
66
+ review_parser.add_argument("--task", required=True, help="task identifier")
67
+ review_parser.add_argument("--commit", required=True, help="reviewed commit SHA")
68
+ review_parser.add_argument("--verdict", required=True, help="review verdict")
69
+ review_parser.add_argument(
70
+ "--finding", action="append", default=[], help="finding id (repeatable)"
71
+ )
72
+ review_parser.add_argument(
73
+ "--advisory-finding",
74
+ action="append",
75
+ default=[],
76
+ help="non-blocking advisory finding id (repeatable)",
77
+ )
78
+ review_parser.add_argument("--role", required=True, help="reviewer role")
79
+ review_parser.add_argument("--vendor", required=True, help="reviewer vendor")
80
+ review_parser.add_argument("--model", required=True, help="reviewer model")
81
+ review_parser.add_argument("--email", required=True, help="reviewer email")
82
+ launch_parser = subparsers.add_parser(
83
+ "review", help="run and record a read-only task review"
84
+ )
85
+ launch_parser.add_argument("--task", required=True, help="task identifier")
86
+ launch_parser.add_argument("--commit", required=True, help="reviewed commit SHA")
87
+ launch_parser.add_argument("--base", required=True, help="comparison base ref")
88
+ launch_parser.add_argument("--role", required=True, help="reviewer role")
89
+ launch_parser.add_argument("--vendor", required=True, help="reviewer vendor")
90
+ launch_parser.add_argument("--model", required=True, help="reviewer model")
91
+ launch_parser.add_argument("--email", required=True, help="reviewer email")
92
+ gate_parser = subparsers.add_parser(
93
+ "gate", help="verify a merge candidate against the journal"
94
+ )
95
+ gate_parser.add_argument(
96
+ "--task",
97
+ default=None,
98
+ help="task identifier (default: derived from the current branch name)",
99
+ )
100
+ gate_parser.add_argument(
101
+ "--commit",
102
+ default=None,
103
+ help="candidate head SHA (default: the current HEAD)",
104
+ )
105
+ gate_parser.add_argument(
106
+ "--base",
107
+ default=None,
108
+ help="merge target ref (default: the repository's default branch)",
109
+ )
110
+ gate_parser.add_argument(
111
+ "--pipeline-sha",
112
+ default=None,
113
+ help="attested pipeline SHA (defaults to AGENTMARSHAL_PIPELINE_OK_SHA)",
114
+ )
115
+ gate_parser.add_argument(
116
+ "--attestation",
117
+ choices=("commit", "ci-required"),
118
+ default="commit",
119
+ help=(
120
+ "pipeline attestation mode: 'commit' (default) requires "
121
+ "--pipeline-sha to equal the candidate; 'ci-required' delegates "
122
+ "attestation to the provider's required checks"
123
+ ),
124
+ )
125
+ complete_parser = subparsers.add_parser(
126
+ "complete", help="gate a candidate and record completion on success"
127
+ )
128
+ complete_parser.add_argument("--task", required=True, help="task identifier")
129
+ complete_parser.add_argument("--commit", required=True, help="candidate head SHA")
130
+ complete_parser.add_argument("--base", required=True, help="merge target ref")
131
+ complete_parser.add_argument(
132
+ "--pipeline-sha",
133
+ default=None,
134
+ help="attested pipeline SHA (defaults to AGENTMARSHAL_PIPELINE_OK_SHA)",
135
+ )
136
+ abandon_parser = subparsers.add_parser(
137
+ "abandon", help="record abandonment of an open task"
138
+ )
139
+ abandon_parser.add_argument("--task", required=True, help="task identifier")
140
+ abandon_parser.add_argument("--reason", required=True, help="abandonment reason")
141
+ session_parser = subparsers.add_parser(
142
+ "record-session", help="record attributed task work"
143
+ )
144
+ session_parser.add_argument("--task", required=True, help="task identifier")
145
+ session_parser.add_argument("--role", required=True, help="worker role")
146
+ session_parser.add_argument("--actor", required=True, help="worker identity")
147
+ session_parser.add_argument(
148
+ "--activity", required=True, help="implementation, review, or other"
149
+ )
150
+ session_parser.add_argument("--outcome", required=True, help="work outcome")
151
+ session_parser.add_argument("--input-tokens", type=int, default=0)
152
+ session_parser.add_argument("--output-tokens", type=int, default=0)
153
+ session_parser.add_argument("--cache-tokens", type=int, default=0)
154
+ report_parser = subparsers.add_parser(
155
+ "report", help="summarize task delegation economics"
156
+ )
157
+ report_parser.add_argument("--task", help="task identifier")
158
+ migrate_parser = subparsers.add_parser(
159
+ "migrate-journal", help="convert a v1 journal into a new v2 journal"
160
+ )
161
+ migrate_parser.add_argument("--source", required=True, type=Path)
162
+ migrate_parser.add_argument("--target", required=True, type=Path)
163
+ migrate_parser.add_argument(
164
+ "--lenient",
165
+ action="store_true",
166
+ help="tolerate pre-v1 header deltas: default safe fields, skip "
167
+ "unreconstructable records, and report each",
168
+ )
169
+ return parser
170
+
171
+
172
+ def _run_init(stderr: TextIO) -> int:
173
+ try:
174
+ project_root = initialize_project(Path.cwd())
175
+ except AlreadyInitializedError as error:
176
+ print(error, file=stderr)
177
+ return 1
178
+ except AgentMarshalProjectError as error:
179
+ print(error, file=stderr)
180
+ return 1
181
+
182
+ print(f"Initialized AgentMarshal project at {project_root}")
183
+ return 0
184
+
185
+
186
+ def _run_doctor() -> int:
187
+ results = run_doctor()
188
+ for result in results:
189
+ status = "OK" if result.ok else "FAIL"
190
+ print(f"{status}: {result.name} — {result.detail}")
191
+ failures = sum(not result.ok for result in results)
192
+ if failures:
193
+ print(f"Summary: {failures} check(s) failed")
194
+ return 1
195
+ print(f"Summary: all {len(results)} checks passed")
196
+ return 0
197
+
198
+
199
+ def _run_open(title: str, scope: list[str], stderr: TextIO) -> int:
200
+ project_root = find_project_root(Path.cwd())
201
+ if project_root is None:
202
+ print(
203
+ "agentmarshal open must be run inside an initialized project", file=stderr
204
+ )
205
+ return 1
206
+ try:
207
+ opened_task = open_task(project_root, title, scope)
208
+ except TaskOpenError as error:
209
+ print(error, file=stderr)
210
+ return 1
211
+ print(opened_task.contract_path)
212
+ print(opened_task.record_path)
213
+ return 0
214
+
215
+
216
+ def _print_task_detail(task: TaskStatus) -> None:
217
+ print(f"ID: {task.task_id}")
218
+ print(f"Status: {task.state}")
219
+ print(f"Title: {task.contract.title}")
220
+ print("Scope:")
221
+ if task.contract.scope:
222
+ for path in task.contract.scope:
223
+ print(f"- {path}")
224
+ else:
225
+ print("- (none)")
226
+ print("Records:")
227
+ for record in task.records:
228
+ if record["record_type"] == "review":
229
+ findings = cast(list[object], record["findings"])
230
+ advisory = cast(list[object], record.get("advisory_findings", []))
231
+ print(
232
+ f"- {record['id']} review {record['created_at']} "
233
+ f"reviewed_commit={str(record['reviewed_commit'])[:7]} "
234
+ f"verdict={record['verdict']} findings={len(findings)} "
235
+ f"advisory={len(advisory)}"
236
+ )
237
+ elif record["record_type"] == "completed":
238
+ print(
239
+ f"- {record['id']} completed {record['created_at']} "
240
+ f"completed_commit={str(record['completed_commit'])[:7]}"
241
+ )
242
+ elif record["record_type"] == "abandoned":
243
+ print(
244
+ f"- {record['id']} abandoned {record['created_at']} "
245
+ f"reason={record['reason']}"
246
+ )
247
+ else:
248
+ print(f"- {record['id']} {record['record_type']} {record['created_at']}")
249
+
250
+
251
+ def _run_submit_review(args: argparse.Namespace, stderr: TextIO) -> int:
252
+ project_root = find_project_root(Path.cwd())
253
+ if project_root is None:
254
+ print(
255
+ "agentmarshal submit-review must be run inside an initialized project",
256
+ file=stderr,
257
+ )
258
+ return 1
259
+ try:
260
+ submitted = submit_review(
261
+ project_root / ".agentmarshal" / "journal",
262
+ args.task,
263
+ args.commit,
264
+ args.verdict,
265
+ args.role,
266
+ args.vendor,
267
+ args.model,
268
+ args.email,
269
+ args.finding,
270
+ args.advisory_finding,
271
+ )
272
+ except ReviewSubmitError as error:
273
+ print(error, file=stderr)
274
+ return 1
275
+ print(submitted.record_path)
276
+ return 0
277
+
278
+
279
+ def _run_review(args: argparse.Namespace, stderr: TextIO) -> int:
280
+ project_root = find_project_root(Path.cwd())
281
+ if project_root is None:
282
+ print(
283
+ "agentmarshal review must be run inside an initialized project",
284
+ file=stderr,
285
+ )
286
+ return 1
287
+ try:
288
+ submitted = launch_review(
289
+ project_root,
290
+ args.task,
291
+ args.commit,
292
+ args.base,
293
+ args.role,
294
+ args.vendor,
295
+ args.model,
296
+ args.email,
297
+ )
298
+ except ReviewLaunchError as error:
299
+ print(error, file=stderr)
300
+ return 1
301
+ print(submitted.record_path)
302
+ return 0
303
+
304
+
305
+ def _run_validate(stderr: TextIO) -> int:
306
+ project_root = find_project_root(Path.cwd())
307
+ if project_root is None:
308
+ print(
309
+ "agentmarshal validate must be run inside an initialized project",
310
+ file=stderr,
311
+ )
312
+ return 1
313
+ report = validate_journal(project_root)
314
+ for line in report.lines:
315
+ print(line)
316
+ if not report.passed:
317
+ print("validate: journal invalid", file=stderr)
318
+ return 1
319
+ print("validate: passed")
320
+ return 0
321
+
322
+
323
+ def _run_gate(args: argparse.Namespace, stderr: TextIO) -> int:
324
+ project_root = find_project_root(Path.cwd())
325
+ if project_root is None:
326
+ print(
327
+ "agentmarshal gate must be run inside an initialized project", file=stderr
328
+ )
329
+ return 1
330
+ pipeline_sha = args.pipeline_sha or os.environ.get("AGENTMARSHAL_PIPELINE_OK_SHA")
331
+ try:
332
+ context = derive_gate_context(project_root, args.task, args.commit, args.base)
333
+ report = run_gate(
334
+ project_root,
335
+ context.task,
336
+ context.commit,
337
+ context.base,
338
+ pipeline_sha,
339
+ attestation=args.attestation,
340
+ )
341
+ except GateError as error:
342
+ print(error, file=stderr)
343
+ return 1
344
+ for line in report.lines:
345
+ print(line)
346
+ if not report.passed:
347
+ print("gate: refused", file=stderr)
348
+ return 1
349
+ print("gate: passed")
350
+ return 0
351
+
352
+
353
+ def _run_complete(args: argparse.Namespace, stderr: TextIO) -> int:
354
+ project_root = find_project_root(Path.cwd())
355
+ if project_root is None:
356
+ print(
357
+ "agentmarshal complete must be run inside an initialized project",
358
+ file=stderr,
359
+ )
360
+ return 1
361
+ pipeline_sha = args.pipeline_sha or os.environ.get("AGENTMARSHAL_PIPELINE_OK_SHA")
362
+ try:
363
+ result = complete_task(
364
+ project_root, args.task, args.commit, args.base, pipeline_sha
365
+ )
366
+ except LifecycleError as error:
367
+ print(error, file=stderr)
368
+ return 1
369
+ for line in result.report.lines:
370
+ print(line)
371
+ if result.record_path is None:
372
+ print("complete: gate refused; task not completed", file=stderr)
373
+ return 1
374
+ print(result.record_path)
375
+ print("completed")
376
+ return 0
377
+
378
+
379
+ def _run_abandon(args: argparse.Namespace, stderr: TextIO) -> int:
380
+ project_root = find_project_root(Path.cwd())
381
+ if project_root is None:
382
+ print(
383
+ "agentmarshal abandon must be run inside an initialized project",
384
+ file=stderr,
385
+ )
386
+ return 1
387
+ try:
388
+ record_path = abandon_task(project_root, args.task, args.reason)
389
+ except LifecycleError as error:
390
+ print(error, file=stderr)
391
+ return 1
392
+ print(record_path)
393
+ print("abandoned")
394
+ return 0
395
+
396
+
397
+ def _run_record_session(args: argparse.Namespace, stderr: TextIO) -> int:
398
+ project_root = find_project_root(Path.cwd())
399
+ if project_root is None:
400
+ print(
401
+ "agentmarshal record-session must be run inside an initialized project",
402
+ file=stderr,
403
+ )
404
+ return 1
405
+ try:
406
+ record_path = record_session(
407
+ project_root / ".agentmarshal" / "journal",
408
+ args.task,
409
+ args.role,
410
+ args.actor,
411
+ args.activity,
412
+ args.outcome,
413
+ args.input_tokens,
414
+ args.output_tokens,
415
+ args.cache_tokens,
416
+ )
417
+ except SessionRecordError as error:
418
+ print(error, file=stderr)
419
+ return 1
420
+ print(record_path)
421
+ return 0
422
+
423
+
424
+ def _run_report(task_id: str | None, stderr: TextIO) -> int:
425
+ project_root = find_project_root(Path.cwd())
426
+ if project_root is None:
427
+ print(
428
+ "agentmarshal report must be run inside an initialized project",
429
+ file=stderr,
430
+ )
431
+ return 1
432
+ try:
433
+ report = build_report(project_root / ".agentmarshal" / "journal", task_id)
434
+ except ReportError as error:
435
+ print(error, file=stderr)
436
+ return 1
437
+ for line in format_report(report, include_summary=task_id is None):
438
+ print(line)
439
+ return 0
440
+
441
+
442
+ def _run_status(task_id: str | None, stderr: TextIO) -> int:
443
+ project_root = find_project_root(Path.cwd())
444
+ if project_root is None:
445
+ print(
446
+ "agentmarshal status must be run inside an initialized project", file=stderr
447
+ )
448
+ return 1
449
+ journal = project_root / ".agentmarshal" / "journal"
450
+ try:
451
+ if task_id is None:
452
+ tasks = list_task_statuses(journal)
453
+ if not tasks:
454
+ print("No tasks.")
455
+ return 0
456
+ for task in tasks:
457
+ print(f"{task.task_id}\t{task.state}\t{task.contract.title}")
458
+ else:
459
+ _print_task_detail(load_task_status(journal, task_id))
460
+ except (OSError, TaskStatusError, ValueError) as error:
461
+ print(error, file=stderr)
462
+ return 1
463
+ return 0
464
+
465
+
466
+ def _run_migrate_journal(
467
+ source: Path, target: Path, stderr: TextIO, *, lenient: bool = False
468
+ ) -> int:
469
+ report: list[str] = []
470
+ try:
471
+ summaries = migrate_journal(source, target, lenient=lenient, report=report)
472
+ except (JournalMigrationError, OSError, ValueError) as error:
473
+ print(error, file=stderr)
474
+ return 1
475
+ for note in report:
476
+ print(f"note: {note}", file=stderr)
477
+ for summary in summaries:
478
+ print(summary)
479
+ if lenient:
480
+ print(f"Migrated {len(summaries)} task(s); {len(report)} lenient note(s).")
481
+ else:
482
+ print(f"Migrated {len(summaries)} task(s).")
483
+ return 0
484
+
485
+
486
+ def main(argv: Sequence[str] | None = None) -> int:
487
+ """Run the AgentMarshal CLI."""
488
+
489
+ parser = _build_parser()
490
+ args = parser.parse_args(argv)
491
+
492
+ if args.command == "init":
493
+ return _run_init(sys.stderr)
494
+ if args.command == "doctor":
495
+ return _run_doctor()
496
+ if args.command == "validate":
497
+ return _run_validate(sys.stderr)
498
+ if args.command == "open":
499
+ return _run_open(args.title, args.scope, sys.stderr)
500
+ if args.command == "status":
501
+ return _run_status(args.task_id, sys.stderr)
502
+ if args.command == "submit-review":
503
+ return _run_submit_review(args, sys.stderr)
504
+ if args.command == "review":
505
+ return _run_review(args, sys.stderr)
506
+ if args.command == "gate":
507
+ return _run_gate(args, sys.stderr)
508
+ if args.command == "complete":
509
+ return _run_complete(args, sys.stderr)
510
+ if args.command == "abandon":
511
+ return _run_abandon(args, sys.stderr)
512
+ if args.command == "record-session":
513
+ return _run_record_session(args, sys.stderr)
514
+ if args.command == "report":
515
+ return _run_report(args.task, sys.stderr)
516
+ if args.command == "migrate-journal":
517
+ return _run_migrate_journal(
518
+ args.source, args.target, sys.stderr, lenient=args.lenient
519
+ )
520
+
521
+ parser.error(f"unknown command: {args.command}")
agentmarshal/doctor.py ADDED
@@ -0,0 +1,143 @@
1
+ """Health checks for AgentMarshal project onboarding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from agentmarshal.project import (
12
+ GitNotAvailableError,
13
+ find_git_root,
14
+ find_project_root,
15
+ project_file_path,
16
+ read_project_file,
17
+ )
18
+
19
+ ExecutableResolver = Callable[[str], str | None]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class DoctorCheck:
24
+ """A named health check and its implementation."""
25
+
26
+ name: str
27
+ run: Callable[[], tuple[bool, str]]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DoctorResult:
32
+ """The result of one onboarding health check."""
33
+
34
+ name: str
35
+ ok: bool
36
+ detail: str
37
+
38
+
39
+ def _check_git_available(resolver: ExecutableResolver) -> tuple[bool, str]:
40
+ executable = resolver("git")
41
+ if executable is None:
42
+ return False, "git executable was not found; install git and try again"
43
+ try:
44
+ result = subprocess.run(
45
+ [executable, "--version"],
46
+ capture_output=True,
47
+ encoding="utf-8",
48
+ check=False,
49
+ )
50
+ except OSError as error:
51
+ return False, f"cannot run git; install or repair git ({error})"
52
+ if result.returncode != 0:
53
+ return False, "git --version failed; install or repair git"
54
+ return True, "git executable is available"
55
+
56
+
57
+ def _check_git_repository(start: Path) -> tuple[bool, str]:
58
+ try:
59
+ git_root = find_git_root(start)
60
+ except (GitNotAvailableError, OSError, RuntimeError, UnicodeError) as error:
61
+ return False, f"cannot determine git repository; {error}"
62
+ if git_root is None:
63
+ return False, "not inside a git repository; run this command from a repository"
64
+ return True, f"git repository root: {git_root}"
65
+
66
+
67
+ def _find_project_root(start: Path) -> tuple[Path | None, str | None]:
68
+ try:
69
+ git_root = find_git_root(start)
70
+ if git_root is None:
71
+ return (
72
+ None,
73
+ "not inside a git repository; run this command from a repository",
74
+ )
75
+ project_root = find_project_root(start, stop_at=git_root)
76
+ except (GitNotAvailableError, OSError, RuntimeError, UnicodeError) as error:
77
+ return None, f"cannot determine project location; {error}"
78
+ if project_root is None:
79
+ return None, "no .agentmarshal/project.json found; run agentmarshal init"
80
+ return project_root, None
81
+
82
+
83
+ def _check_project_initialized(start: Path) -> tuple[bool, str]:
84
+ project_root, error = _find_project_root(start)
85
+ if error is not None:
86
+ return False, error
87
+ assert project_root is not None
88
+ path = project_file_path(project_root)
89
+ try:
90
+ with path.open("r", encoding="utf-8-sig") as project_file:
91
+ project_file.read()
92
+ except OSError as error:
93
+ return False, f"cannot read {path}; repair the project file ({error})"
94
+ return True, f"project file: {project_file_path(project_root)}"
95
+
96
+
97
+ def _check_project_schema(start: Path) -> tuple[bool, str]:
98
+ project_root, discovery_error = _find_project_root(start)
99
+ if discovery_error is not None:
100
+ return False, discovery_error
101
+ assert project_root is not None
102
+ path = project_file_path(project_root)
103
+ try:
104
+ project = read_project_file(path)
105
+ except (OSError, ValueError) as error:
106
+ return False, f"cannot parse {path}; repair the project file ({error})"
107
+ if project.get("schema") != 1:
108
+ return False, f"unsupported project schema in {path}; expected schema 1"
109
+ return True, "project schema 1 is supported"
110
+
111
+
112
+ def doctor_checks(
113
+ start: Path | None = None, resolver: ExecutableResolver = shutil.which
114
+ ) -> list[DoctorCheck]:
115
+ """Build the data-driven onboarding checks for *start*."""
116
+
117
+ search_start = Path.cwd() if start is None else start
118
+ return [
119
+ DoctorCheck("git", lambda: _check_git_available(resolver)),
120
+ DoctorCheck("git repository", lambda: _check_git_repository(search_start)),
121
+ DoctorCheck(
122
+ "project initialized", lambda: _check_project_initialized(search_start)
123
+ ),
124
+ DoctorCheck("project schema", lambda: _check_project_schema(search_start)),
125
+ ]
126
+
127
+
128
+ def run_doctor(
129
+ start: Path | None = None, resolver: ExecutableResolver = shutil.which
130
+ ) -> Sequence[DoctorResult]:
131
+ """Run onboarding health checks without changing project state."""
132
+
133
+ results: list[DoctorResult] = []
134
+ for check in doctor_checks(start, resolver):
135
+ try:
136
+ ok, detail = check.run()
137
+ except Exception as error:
138
+ ok = False
139
+ detail = (
140
+ f"check could not run; verify repository access and retry ({error})"
141
+ )
142
+ results.append(DoctorResult(check.name, ok, detail))
143
+ return tuple(results)