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.
- agentmarshal/__init__.py +3 -0
- agentmarshal/__main__.py +7 -0
- agentmarshal/cli.py +521 -0
- agentmarshal/doctor.py +143 -0
- agentmarshal/journal/__init__.py +67 -0
- agentmarshal/journal/attestation.py +63 -0
- agentmarshal/journal/backfill.py +369 -0
- agentmarshal/journal/capture.py +293 -0
- agentmarshal/journal/complete.py +83 -0
- agentmarshal/journal/contracts.py +108 -0
- agentmarshal/journal/gate.py +412 -0
- agentmarshal/journal/gate_context.py +125 -0
- agentmarshal/journal/open_task.py +135 -0
- agentmarshal/journal/records.py +623 -0
- agentmarshal/journal/report.py +92 -0
- agentmarshal/journal/review.py +280 -0
- agentmarshal/journal/session.py +52 -0
- agentmarshal/journal/status.py +114 -0
- agentmarshal/journal/submit_review.py +58 -0
- agentmarshal/journal/validate.py +130 -0
- agentmarshal/migrate.py +494 -0
- agentmarshal/project.py +210 -0
- agentmarshal/py.typed +0 -0
- agentmarshal-0.1.0.dist-info/METADATA +84 -0
- agentmarshal-0.1.0.dist-info/RECORD +28 -0
- agentmarshal-0.1.0.dist-info/WHEEL +4 -0
- agentmarshal-0.1.0.dist-info/entry_points.txt +3 -0
- agentmarshal-0.1.0.dist-info/licenses/LICENSE +201 -0
agentmarshal/__init__.py
ADDED
agentmarshal/__main__.py
ADDED
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)
|