outerloop-science 0.1.0.dev0__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 (52) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +213 -0
  4. outerloop/appmanifest.py +198 -0
  5. outerloop/attempt.py +3481 -0
  6. outerloop/brief.py +515 -0
  7. outerloop/cli.py +439 -0
  8. outerloop/climbboard.py +1145 -0
  9. outerloop/compute.py +482 -0
  10. outerloop/contract.py +483 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +586 -0
  14. outerloop/followup.py +2143 -0
  15. outerloop/github.py +1486 -0
  16. outerloop/harness.py +1449 -0
  17. outerloop/housekeeping.py +167 -0
  18. outerloop/init.py +313 -0
  19. outerloop/intake.py +129 -0
  20. outerloop/limits.py +80 -0
  21. outerloop/markers.py +48 -0
  22. outerloop/measure.py +523 -0
  23. outerloop/orchestrator.py +1901 -0
  24. outerloop/panel.py +188 -0
  25. outerloop/paths.py +27 -0
  26. outerloop/posting.py +160 -0
  27. outerloop/progress.py +170 -0
  28. outerloop/py.typed +0 -0
  29. outerloop/review.py +611 -0
  30. outerloop/review_agent.py +263 -0
  31. outerloop/review_agent_cli.py +209 -0
  32. outerloop/review_post_cli.py +162 -0
  33. outerloop/review_summarize_cli.py +163 -0
  34. outerloop/role_runner.py +229 -0
  35. outerloop/roles.py +247 -0
  36. outerloop/rolespec.py +89 -0
  37. outerloop/runstate.py +385 -0
  38. outerloop/steward.py +852 -0
  39. outerloop/style.py +12 -0
  40. outerloop/syscall.py +977 -0
  41. outerloop/syscall_cli.py +531 -0
  42. outerloop/tick.py +3166 -0
  43. outerloop/verifier.py +403 -0
  44. outerloop/verify_agent.py +149 -0
  45. outerloop/verify_agent_cli.py +95 -0
  46. outerloop/verify_post_cli.py +116 -0
  47. outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
  48. outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
  49. outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
  50. outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  51. outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  52. outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,531 @@
1
+ #!/usr/bin/env python3
2
+ """The research syscall tool — the one agent-facing surface every role uses to
3
+ talk to the kernel (research-loop.md, "one syscall"; role-cli.md, "one CLI per
4
+ role, gated by RoleSpec").
5
+
6
+ A syscall is TYPED, and the kernel dispatches by type. The AUTHOR's syscalls run
7
+ experiments and hibernate:
8
+
9
+ python .outerloop/syscall launch --name train --minutes 90 \\
10
+ --artifact results/curve.json -- uv run python train.py --lr 3e-4
11
+ python .outerloop/syscall note "compare with the lr sweep"
12
+ python .outerloop/syscall submit # seal + gate + panel on this tree
13
+ python .outerloop/syscall sleep # then END YOUR TURN to hibernate
14
+
15
+ The JUDGE's syscalls record a verdict and exit — `conclude` is the judge's
16
+ `exit()`, carrying its findings:
17
+
18
+ python .outerloop/syscall finding --file solver.py --line 42 \\
19
+ --confidence high --summary "off-by-one" --detail "skips last index" --blocking
20
+ python .outerloop/syscall conclude --notes "one blocking defect; rest clean"
21
+
22
+ Which verbs a role may use is set by its RoleSpec (the brief tells the role
23
+ which). Every verb STAGES into `.outerloop/request.json`; the committing
24
+ verbs (`sleep`, `conclude`) write the typed ABI to `.outerloop/syscall.json`
25
+ (what the kernel reads after the session ends) — so building a request and
26
+ committing it are separate acts.
27
+
28
+ This file is STANDALONE by contract: the kernel copies its source into the
29
+ sandbox at `.outerloop/syscall` (the target repo does not have autoresearch
30
+ installed), so it imports only the stdlib. The validation here is for FAST,
31
+ IN-SESSION feedback only; the kernel re-validates every field authoritatively
32
+ when it reads the ABI (`syscall.py`) — this tool is a convenience layer, never a
33
+ trust boundary, so a role that writes the ABI directly is still fully checked.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import contextlib
40
+ import json
41
+ import re
42
+ import shlex
43
+ import sys
44
+ from pathlib import Path
45
+
46
+ # Mirror of syscall.py's bounds for local feedback. syscall.py is authoritative;
47
+ # keep these in sync (a drift only makes the tool's warning stale, never unsafe —
48
+ # the kernel still enforces the real limits).
49
+ DIR = ".outerloop" # default; the installed tool roots at its own location
50
+ REQUEST = "request.json" # staging (tool-owned)
51
+ ABI = "syscall.json" # committed syscall the kernel reads
52
+ BUDGET = "budget.json" # kernel-written: remaining counts, for `status`
53
+ # author (launch/sleep) bounds
54
+ MAX_LAUNCHES = 8
55
+ MAX_COMMAND_CHARS = 2_000
56
+ MAX_ARTIFACTS = 8
57
+ MAX_NOTE_CHARS = 2_000
58
+ MAX_LAUNCH_MINUTES = 240
59
+ MAX_LAUNCH_ARRAY = 16 # jobs one launch may fan out to (a sweep)
60
+ # a submit's declared eval walltime (matches the kernel's backstop)
61
+ MAX_EVAL_MINUTES = 1440
62
+ # judge (finding/conclude) bounds
63
+ CONFIDENCES = ("low", "medium", "high")
64
+ KINDS = ("change", "suggestion", "question", "note")
65
+ MAX_TEXT = 6_000 # per summary/detail/notes/category
66
+ MAX_FINDINGS = 200
67
+ _NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
68
+
69
+
70
+ class ToolError(Exception):
71
+ """A bad invocation: printed to stderr, exit 2, nothing staged."""
72
+
73
+
74
+ def _rel_path_ok(path: str) -> bool:
75
+ """MUST match syscall._rel_path_ok exactly — the CLI's fast check has to
76
+ accept precisely what the kernel accepts, or the author burns a sleep on a
77
+ post-session validation error (the very thing the tool exists to prevent).
78
+ Rejects absolute/`~`, backslashes, over-long, and any empty/`.`/`..`
79
+ component (so ``, `.`, `out/./x`, `out//x` all fail here as they do there)."""
80
+ if not path or len(path) > 500 or path.startswith(("/", "~")) or "\\" in path:
81
+ return False
82
+ return all(p not in ("", ".", "..") for p in path.split("/"))
83
+
84
+
85
+ def _dir(root: Path) -> Path:
86
+ d = root / DIR
87
+ d.mkdir(exist_ok=True)
88
+ return d
89
+
90
+
91
+ def _load_staged(root: Path) -> dict:
92
+ f = root / DIR / REQUEST
93
+ try:
94
+ data = json.loads(f.read_text())
95
+ except FileNotFoundError:
96
+ return {"launches": [], "note": "", "submit": False, "findings": [], "notes": ""}
97
+ except (OSError, json.JSONDecodeError) as exc:
98
+ raise ToolError(f"staged request is unreadable ({exc}); run `cancel` to reset") from exc
99
+ # tolerate a partial file: default any missing family so either role's verbs work
100
+ for key, empty in (
101
+ ("launches", []),
102
+ ("note", ""),
103
+ ("submit", False),
104
+ ("eval_minutes", None),
105
+ ("findings", []),
106
+ ("notes", ""),
107
+ ):
108
+ data.setdefault(key, empty)
109
+ return data
110
+
111
+
112
+ def _save_staged(root: Path, data: dict) -> None:
113
+ (_dir(root) / REQUEST).write_text(json.dumps(data, indent=2))
114
+
115
+
116
+ def _budget_line(root: Path) -> str:
117
+ try:
118
+ b = json.loads((root / DIR / BUDGET).read_text())
119
+ gpu = b.get("gpu_hours_remaining")
120
+ gpu_part = f", {gpu:g} GPU-hours" if isinstance(gpu, int | float) else ""
121
+ return (
122
+ f"budget: {b.get('launches_remaining', '?')} launches, "
123
+ f"{b.get('sleeps_remaining', '?')} sleeps{gpu_part} remaining"
124
+ )
125
+ except (OSError, json.JSONDecodeError):
126
+ return "budget: (unknown)"
127
+
128
+
129
+ # --- author syscalls: launch / note / sleep --------------------------------
130
+
131
+
132
+ def cmd_launch(root: Path, args: argparse.Namespace) -> str:
133
+ # shlex.join, NOT " ".join: the shell that invoked this CLI already split
134
+ # `-- python train.py --label "a b"` into tokens, so re-quote them so the
135
+ # eventual `sh -c "$(cat command.txt)"` re-parses the SAME tokens (a plain
136
+ # join would collapse `a b` into two args).
137
+ command = shlex.join(args.command).strip()
138
+ if not command:
139
+ raise ToolError("launch needs a command after `--`")
140
+ if len(command) > MAX_COMMAND_CHARS:
141
+ raise ToolError(f"command exceeds {MAX_COMMAND_CHARS} chars")
142
+ if not _NAME.match(args.name):
143
+ raise ToolError(f"--name must match {_NAME.pattern}")
144
+ if args.minutes < 1:
145
+ raise ToolError("--minutes must be a positive integer")
146
+ minutes = min(args.minutes, MAX_LAUNCH_MINUTES)
147
+ array = args.array
148
+ if array < 1:
149
+ raise ToolError("--array must be a positive integer")
150
+ array = min(array, MAX_LAUNCH_ARRAY)
151
+ if len(args.artifact) > MAX_ARTIFACTS:
152
+ raise ToolError(f"at most {MAX_ARTIFACTS} --artifact paths")
153
+ for a in args.artifact:
154
+ if not _rel_path_ok(a):
155
+ raise ToolError(f"--artifact {a!r} must be a repo-relative file path, no traversal")
156
+ staged = _load_staged(root)
157
+ if any(la["name"] == args.name for la in staged["launches"]):
158
+ raise ToolError(f"a launch named {args.name!r} is already staged")
159
+ if len(staged["launches"]) >= MAX_LAUNCHES:
160
+ raise ToolError(f"at most {MAX_LAUNCHES} launches per sleep")
161
+ staged["launches"].append(
162
+ {
163
+ "name": args.name,
164
+ "command": command,
165
+ "minutes": minutes,
166
+ "artifacts": args.artifact,
167
+ "array": array,
168
+ }
169
+ )
170
+ _save_staged(root, staged)
171
+ return (
172
+ f"staged launch {args.name!r} ({minutes} min"
173
+ + (f" x {array} jobs, SWEEP_INDEX 0..{array - 1}" if array > 1 else "")
174
+ + f"); {len(staged['launches'])} staged. "
175
+ f"Add more, or `sleep` to run them. {_budget_line(root)}."
176
+ )
177
+
178
+
179
+ def cmd_note(root: Path, args: argparse.Namespace) -> str:
180
+ note = args.text
181
+ if len(note) > MAX_NOTE_CHARS:
182
+ raise ToolError(f"note exceeds {MAX_NOTE_CHARS} chars")
183
+ staged = _load_staged(root)
184
+ staged["note"] = note
185
+ _save_staged(root, staged)
186
+ return "note saved (delivered back to you on wake)."
187
+
188
+
189
+ def cmd_submit(root: Path, args: argparse.Namespace) -> str:
190
+ staged = _load_staged(root)
191
+ staged["submit"] = True
192
+ minutes = getattr(args, "minutes", None)
193
+ if minutes is not None:
194
+ if minutes < 1:
195
+ raise ToolError("--minutes must be a positive integer")
196
+ staged["eval_minutes"] = min(minutes, MAX_EVAL_MINUTES)
197
+ _save_staged(root, staged)
198
+ declared = staged.get("eval_minutes")
199
+ walltime = (
200
+ f"each paired eval gets {declared} min of walltime (your declaration; "
201
+ "2 evals x minutes x GPUs draws on your GPU-hour budget)"
202
+ if declared
203
+ else "each paired eval gets the contract's default walltime (declare more with --minutes)"
204
+ )
205
+ return (
206
+ "staged submit: on `sleep` your current tree is SEALED and measured "
207
+ "against the baseline, and the review panel reads the claim; you will "
208
+ f"be woken with the result (published if it clears cleanly). {walltime}. "
209
+ f"{_budget_line(root)}."
210
+ )
211
+
212
+
213
+ def cmd_sleep(root: Path, _args: argparse.Namespace) -> str:
214
+ staged = _load_staged(root)
215
+ # commit the SLEEP syscall -> the ABI the kernel reads; then END THE TURN.
216
+ payload = {
217
+ "type": "sleep",
218
+ "launches": staged["launches"],
219
+ "note": staged["note"],
220
+ "submit": bool(staged["submit"]),
221
+ }
222
+ if staged["submit"] and staged.get("eval_minutes"):
223
+ payload["eval_minutes"] = int(staged["eval_minutes"])
224
+ (_dir(root) / ABI).write_text(json.dumps(payload))
225
+ (root / DIR / REQUEST).unlink(missing_ok=True)
226
+ n = len(staged["launches"])
227
+ what = f"{n} launch(es)" if n else "a checkpoint (no launches)"
228
+ if staged["submit"]:
229
+ what += " + a submit (seal, gate, panel)"
230
+ return (
231
+ f"committed {what}. END YOUR TURN NOW to hibernate — you will be woken "
232
+ "with the results. (If you keep working, the sleep still triggers when "
233
+ "the session ends.)"
234
+ )
235
+
236
+
237
+ # --- judge syscalls: finding / conclude ------------------------------------
238
+
239
+
240
+ def cmd_finding(root: Path, args: argparse.Namespace) -> str:
241
+ if not args.file.strip():
242
+ raise ToolError("--file must not be empty")
243
+ if args.confidence not in CONFIDENCES:
244
+ raise ToolError(f"--confidence must be one of {CONFIDENCES}")
245
+ if args.kind not in KINDS:
246
+ raise ToolError(f"--kind must be one of {KINDS}")
247
+ if args.line is not None and args.line < 1:
248
+ raise ToolError("--line is 1-indexed; omit it for a non-local finding")
249
+ for label, text in (("--summary", args.summary), ("--detail", args.detail)):
250
+ if not text.strip():
251
+ raise ToolError(f"{label} must not be empty")
252
+ if len(text) > MAX_TEXT:
253
+ raise ToolError(f"{label} exceeds {MAX_TEXT} chars")
254
+ if args.category and len(args.category) > MAX_TEXT:
255
+ raise ToolError(f"--category exceeds {MAX_TEXT} chars")
256
+ staged = _load_staged(root)
257
+ if len(staged["findings"]) >= MAX_FINDINGS:
258
+ raise ToolError(f"at most {MAX_FINDINGS} findings")
259
+ finding = {
260
+ "file": args.file,
261
+ "line": args.line, # None when --line omitted: a non-local finding
262
+ "confidence": args.confidence,
263
+ "summary": args.summary,
264
+ "detail": args.detail,
265
+ "blocking": bool(args.blocking),
266
+ "kind": args.kind,
267
+ }
268
+ if args.category:
269
+ finding["category"] = args.category # verifier gaming-taxonomy; omitted otherwise
270
+ staged["findings"].append(finding)
271
+ _save_staged(root, staged)
272
+ tag = "BLOCKING" if args.blocking else args.kind
273
+ where = f"{args.file}:{args.line or '?'}"
274
+ return f"recorded {tag} finding on {where} ({len(staged['findings'])} so far)."
275
+
276
+
277
+ def cmd_conclude(root: Path, args: argparse.Namespace) -> str:
278
+ if len(args.notes) > MAX_TEXT:
279
+ raise ToolError(f"--notes exceeds {MAX_TEXT} chars")
280
+ staged = _load_staged(root)
281
+ # commit the VERDICT syscall -> the ABI the kernel reads; then END THE TURN.
282
+ payload = {"type": "verdict", "findings": staged["findings"], "notes": args.notes}
283
+ (_dir(root) / ABI).write_text(json.dumps(payload))
284
+ (root / DIR / REQUEST).unlink(missing_ok=True)
285
+ n = len(staged["findings"])
286
+ blocking = sum(1 for f in staged["findings"] if f.get("blocking"))
287
+ return (
288
+ f"verdict recorded: {n} finding(s), {blocking} blocking. This is your "
289
+ "final answer — end your turn."
290
+ )
291
+
292
+
293
+ # --- shared: status / cancel -----------------------------------------------
294
+
295
+
296
+ def cmd_status(root: Path, _args: argparse.Namespace) -> str:
297
+ staged = _load_staged(root)
298
+ lines: list[str] = []
299
+ if staged["launches"] or staged["submit"] or (root / DIR / BUDGET).exists():
300
+ lines.append(f"{len(staged['launches'])} launch(es) staged; {_budget_line(root)}.")
301
+ for la in staged["launches"]:
302
+ arts = (" -> " + ", ".join(la["artifacts"])) if la.get("artifacts") else ""
303
+ width = f" x{la['array']}" if int(la.get("array") or 1) > 1 else ""
304
+ lines.append(f" - {la['name']} ({la['minutes']} min{width}): {la['command']}{arts}")
305
+ if staged["submit"]:
306
+ lines.append(" submit staged: `sleep` seals this tree for the gate + panel")
307
+ if staged.get("note"):
308
+ lines.append(f" note: {staged['note']}")
309
+ if staged["findings"]:
310
+ lines.append(f"{len(staged['findings'])} finding(s) staged:")
311
+ for f in staged["findings"]:
312
+ tag = "BLOCKING" if f.get("blocking") else f.get("kind", "note")
313
+ lines.append(f" - [{tag}] {f['file']}:{f.get('line') or '?'} — {f['summary']}")
314
+ return "\n".join(lines) if lines else "nothing staged."
315
+
316
+
317
+ def cmd_cancel(root: Path, _args: argparse.Namespace) -> str:
318
+ (root / DIR / REQUEST).unlink(missing_ok=True)
319
+ return "staged request discarded."
320
+
321
+
322
+ def build_parser() -> argparse.ArgumentParser:
323
+ p = argparse.ArgumentParser(prog="syscall", description="research syscall tool")
324
+ sub = p.add_subparsers(dest="cmd", required=True)
325
+ # author verbs
326
+ la = sub.add_parser("launch", help="stage a job to run outside the sandbox")
327
+ la.add_argument("--name", required=True, help="your handle for this job (a-z0-9-)")
328
+ la.add_argument("--minutes", type=int, default=30, help="walltime ask (clamped to 240)")
329
+ la.add_argument(
330
+ "--array",
331
+ type=int,
332
+ default=1,
333
+ help="fan out to N jobs of this command, each with SWEEP_INDEX=0..N-1 "
334
+ "and its own results/<name>/<i>/ (a sweep; counts as one launch, "
335
+ "N times the GPU-hours)",
336
+ )
337
+ la.add_argument(
338
+ "--artifact",
339
+ action="append",
340
+ default=[],
341
+ help="repo-relative file to bring back (repeatable)",
342
+ )
343
+ la.add_argument("command", nargs=argparse.REMAINDER, help="-- then the command to run")
344
+ no = sub.add_parser("note", help="save a note to yourself, echoed back on wake")
345
+ no.add_argument("text")
346
+ su = sub.add_parser(
347
+ "submit",
348
+ help="stage a submit: on sleep, seal this tree for the gate + review panel",
349
+ )
350
+ su.add_argument(
351
+ "--minutes",
352
+ type=int,
353
+ default=None,
354
+ help="walltime for each paired gate eval (default: the contract's; "
355
+ "2 evals x minutes x GPUs draws on your GPU-hour budget)",
356
+ )
357
+ sub.add_parser("sleep", help="commit staged launches/submit; then end your turn")
358
+ # judge verbs
359
+ fi = sub.add_parser("finding", help="record one finding")
360
+ fi.add_argument("--file", required=True)
361
+ fi.add_argument(
362
+ "--line", type=int, default=None, help="1-indexed; omit for a non-local finding"
363
+ )
364
+ fi.add_argument("--confidence", required=True, help=f"one of {CONFIDENCES}")
365
+ fi.add_argument("--summary", required=True, help="one-line claim")
366
+ fi.add_argument("--detail", required=True, help="the evidence")
367
+ fi.add_argument("--blocking", action="store_true", help="a confirmed defect that gates merge")
368
+ fi.add_argument("--kind", default="note", help=f"one of {KINDS}")
369
+ fi.add_argument("--category", default="", help="verifier gaming taxonomy; omit for review")
370
+ co = sub.add_parser("conclude", help="commit the verdict; then end your turn")
371
+ co.add_argument("--notes", default="", help="summary the reader sees")
372
+ # shared verbs
373
+ rp = sub.add_parser(
374
+ "reports",
375
+ help="past attempts' research reports: no names = a summary list; "
376
+ "names = the full reports (several in one call)",
377
+ )
378
+ rp.add_argument("names", nargs="*", help="report file names from the summary list")
379
+ sub.add_parser("status", help="show staged syscalls and remaining budget")
380
+ sub.add_parser(
381
+ "siblings",
382
+ help="what the other agents were working on as of this session's start",
383
+ )
384
+ sync_p = sub.add_parser(
385
+ "sync",
386
+ help="refresh origin/* refs now, waiting inside this session "
387
+ "(up to one kernel cycle; refs also refresh free at every wake)",
388
+ )
389
+ sync_p.add_argument(
390
+ "--minutes",
391
+ type=int,
392
+ default=35,
393
+ help="how long to wait before giving up (0 = probe and return)",
394
+ )
395
+ sub.add_parser("cancel", help="discard the staged request")
396
+ return p
397
+
398
+
399
+ def cmd_sync(root: Path, args) -> str:
400
+ """Ask the kernel for fresh origin/* refs and wait, inside this session's
401
+ own clock. The kernel acts on its next cycle (cadence up to 30 minutes),
402
+ so the default wait covers one full cycle; a timeout is not an error —
403
+ the refs refresh at the next wake regardless. Stdlib only: this file is
404
+ copied into workspaces standalone, so the marker names are inlined
405
+ (kernel counterparts live in outerloop.syscall)."""
406
+ import time
407
+
408
+ channel = root / DIR
409
+ done = channel / "sync-done"
410
+ request = channel / "sync-request"
411
+ request.touch()
412
+ started = request.stat().st_mtime
413
+ minutes = getattr(args, "minutes", None)
414
+ deadline = time.time() + 60 * int(35 if minutes is None else minutes)
415
+
416
+ def acknowledged() -> bool:
417
+ # the kernel writes the serviced request's mtime as the marker's
418
+ # content; ours is acknowledged once that is >= our request time
419
+ try:
420
+ return float(done.read_text() or 0) >= started
421
+ except (OSError, ValueError):
422
+ return False
423
+
424
+ while True:
425
+ # check FIRST: an already-stamped completion (or --minutes 0 as a
426
+ # pure probe) must be seen before any deadline math
427
+ if acknowledged():
428
+ return (
429
+ "origin/* refs refreshed — read the base branch and "
430
+ "sibling branches from your local refs."
431
+ )
432
+ if time.time() >= deadline:
433
+ return (
434
+ "sync timed out waiting for the kernel's next cycle; "
435
+ "continuing with current refs (they refresh at your next "
436
+ "wake regardless)."
437
+ )
438
+ time.sleep(15)
439
+
440
+
441
+ def cmd_siblings(root: Path, _args) -> str:
442
+ """The fleet snapshot the kernel wrote at session start (informational;
443
+ other agents may have moved on since)."""
444
+ try:
445
+ entries = json.loads((root / DIR / "siblings.json").read_text())
446
+ except (OSError, ValueError):
447
+ entries = []
448
+ if not isinstance(entries, list) or not entries:
449
+ return "no sibling activity known."
450
+ lines = ["as of this session's start:"]
451
+ for e in entries:
452
+ if not isinstance(e, dict):
453
+ continue
454
+ who = str(e.get("agent", "?"))[:64]
455
+ state = str(e.get("state", ""))[:32]
456
+ phase = str(e.get("phase", ""))[:32]
457
+ direction = str(e.get("direction", ""))[:160]
458
+ label = f"{state}/{phase}" if phase else state
459
+ lines.append(f" - {who} ({label}): {direction}" if direction else f" - {who} ({label})")
460
+ lines.append("prefer a direction no sibling is actively on, unless you have a distinct angle.")
461
+ return "\n".join(lines)
462
+
463
+
464
+ def cmd_reports(root: Path, args) -> str:
465
+ """The research-report archive the kernel fetched for this run. With no
466
+ names: one summary line per report, newest first. With names: those
467
+ reports in full, in the order asked."""
468
+ archive = root / "reports"
469
+ if not archive.is_dir():
470
+ return "no report archive in this run (a first attempt on the target, or fetch failed)"
471
+ if args.names:
472
+ parts = []
473
+ for name in args.names:
474
+ f = archive / name
475
+ if Path(name).name != name or not f.is_file():
476
+ raise ToolError(f"no such report: {name} (run `reports` for the list)")
477
+ parts.append(f"=== {name}\n{f.read_text()}")
478
+ return "\n\n".join(parts)
479
+ lines = []
480
+ for f in sorted(archive.glob("*.md"), reverse=True):
481
+ head = ""
482
+ for raw in f.read_text().splitlines():
483
+ text = raw.strip()
484
+ if text.startswith("Outcome:"):
485
+ head = text
486
+ break
487
+ if not head and text and not text.startswith("#"):
488
+ head = text
489
+ lines.append(f"{f.name} {head[:120]}")
490
+ if not lines:
491
+ return "the report archive is empty"
492
+ return "\n".join(lines) + "\n(pass names to read full reports, several at once)"
493
+
494
+
495
+ _HANDLERS = {
496
+ "launch": cmd_launch,
497
+ "note": cmd_note,
498
+ "submit": cmd_submit,
499
+ "sleep": cmd_sleep,
500
+ "finding": cmd_finding,
501
+ "conclude": cmd_conclude,
502
+ "reports": cmd_reports,
503
+ "siblings": cmd_siblings,
504
+ "sync": cmd_sync,
505
+ "status": cmd_status,
506
+ "cancel": cmd_cancel,
507
+ }
508
+
509
+
510
+ def main(argv: list[str], root: Path | None = None) -> int:
511
+ args = build_parser().parse_args(argv)
512
+ # argparse REMAINDER keeps a leading "--"; drop it for a clean command
513
+ if getattr(args, "command", None) and args.command and args.command[0] == "--":
514
+ args.command = args.command[1:]
515
+ # Root at the tool's own install location (<workspace>/.autoresearch/
516
+ # syscall -> the workspace), NEVER the caller's cwd: an agent may invoke
517
+ # the tool from a subdirectory or from another working directory entirely
518
+ # (hermes starts in its per-run home), and a cwd-rooted channel would
519
+ # silently commit the syscall where the kernel never looks.
520
+ root = root or Path(__file__).resolve().parent.parent
521
+ try:
522
+ print(_HANDLERS[args.cmd](root, args))
523
+ return 0
524
+ except ToolError as exc:
525
+ print(f"error: {exc}", file=sys.stderr)
526
+ return 2
527
+
528
+
529
+ if __name__ == "__main__": # pragma: no cover - exercised via main(argv) in tests
530
+ with contextlib.suppress(BrokenPipeError):
531
+ sys.exit(main(sys.argv[1:]))