Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .DS_Store
Binary file not shown.
584 changes: 60 additions & 524 deletions CLAUDE.md

Large diffs are not rendered by default.

29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ During `argusbot init`, first choose control channel (`1. Telegram`, `2. Feishu
- Single-word operator entrypoint: `argusbot` (first run setup, later auto-attach monitor).
- Token-exclusive daemon lock: one active daemon per Telegram token.
- Operator message history persisted to markdown and fed to reviewer decisions.
- Final task report generated after reviewer `done`, with optional `--final-report-file` output path and notifier delivery when ready.
- PPTX auto-generation for run handoff: builds a presentation-ready slide deck summarizing the completed work.
- Interactive PPTX opt-in: when running `argusbot-run` interactively, the CLI asks whether to generate a PPTX report before starting. Answer `Y` (default) to enable or `n` to skip. Daemon-launched runs (Telegram/Feishu) also ask via the control channel before each `/run` launch — reply `Y` or `N` to confirm. Use `--pptx-report` / `--no-pptx-report` to bypass the prompt.
- Final handoff artifacts generated after reviewer `done`: Markdown via `--final-report-file` and PPTX via `--pptx-report-file`, with notifier delivery when ready.
- Run archive persisted as JSONL with date/workspace/session metadata for resume continuity.
- Utility scripts: start/kill/watch daemon logs, plus sanitized cross-project setup examples.

Expand All @@ -121,6 +123,16 @@ source .venv/bin/activate
pip install -e .
```

### PPTX Report Dependencies (optional)

The PPTX run report generator uses a Node.js script. To enable it:

```bash
npm install # installs pptxgenjs and other JS dependencies
```

If `node` is not available, PPTX generation is silently skipped and does not block the loop.

## GitHub Copilot via `copilot-proxy`

ArgusBot can route Codex backend calls through a local `copilot-proxy` checkout, so main/reviewer/planner/BTW runs can use GitHub Copilot-backed quota instead of OpenAI API billing.
Expand Down Expand Up @@ -250,6 +262,20 @@ argusbot-run \
"Implement feature X and keep iterating until tests pass"
```

Report artifact example:

```bash
argusbot-run \
--state-file .argusbot/state.json \
--final-report-file .argusbot/review_summaries/final-task-report.md \
--pptx-report-file .argusbot/run-report.pptx \
"实现功能并同时产出 Markdown 与 PPTX 汇报"
```

This keeps both handoff artifacts in predictable paths. The PPTX report is also the file pushed by Telegram/Feishu when the run emits `pptx.report.ready`.

Release note: if `--pptx-report-file` is not passed, ArgusBot still resolves a default PPTX artifact path under the run artifact directory using the standard file name `run-report.pptx`.

Common options:

- `--runner-backend {codex,claude,copilot}`: select the execution backend
Expand All @@ -263,6 +289,7 @@ Common options:
- `--full-auto`: request automatic tool approval mode when supported by the selected backend
- `--state-file <file>`: write round-by-round state JSON
- `--final-report-file <file>`: write the final handoff Markdown report after reviewer `done`
- `--pptx-report-file <file>`: write the auto-generated PPTX run report (default artifact name: `run-report.pptx`)
- `--plan-report-file <file>`: write the latest planner markdown snapshot
- `--plan-todo-file <file>`: write the latest planner TODO board markdown
- `--plan-update-interval-seconds 1800`: run background planning sweeps every 30 minutes
Expand Down
Binary file removed codex_autoloop/.DS_Store
Binary file not shown.
14 changes: 14 additions & 0 deletions codex_autoloop/adapters/event_sinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ def handle_event(self, event: dict[str, object]) -> None:
if event_type == "final.report.ready":
self._stop_stream_reporter(flush=False)
_send_final_report_via_notifier(self.notifier, event)
elif event_type == "pptx.report.ready":
_send_pptx_report_via_notifier(self.notifier, event)
elif event_type == "loop.completed":
self._stop_stream_reporter(flush=False)
self.notifier.notify_event(event)
Expand Down Expand Up @@ -151,6 +153,8 @@ def handle_event(self, event: dict[str, object]) -> None:
if event_type == "final.report.ready":
self._stop_stream_reporter(flush=False)
_send_final_report_via_notifier(self.notifier, event)
elif event_type == "pptx.report.ready":
_send_pptx_report_via_notifier(self.notifier, event)
elif event_type == "loop.completed":
self._stop_stream_reporter(flush=False)
self.notifier.notify_event(event)
Expand Down Expand Up @@ -190,6 +194,16 @@ def _send_final_report_via_notifier(notifier, event: dict[str, object]) -> None:
notifier.send_local_file(path, caption="ArgusBot final task report")


def _send_pptx_report_via_notifier(notifier, event: dict[str, object]) -> None:
raw_path = str(event.get("path") or "").strip()
if not raw_path:
return
path = Path(raw_path)
if not path.exists():
return
notifier.send_local_file(path, caption="ArgusBot run report (PPTX)")


def _render_final_report_message(event: dict[str, object]) -> str:
raw_path = str(event.get("path") or "").strip()
if not raw_path:
Expand Down
13 changes: 13 additions & 0 deletions codex_autoloop/apps/cli_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
resolve_btw_messages_file,
resolve_final_report_file,
resolve_plan_overview_file,
resolve_pptx_report_file,
resolve_review_summaries_dir,
resolve_operator_messages_file,
)
Expand Down Expand Up @@ -72,6 +73,15 @@ def run_cli(args: Namespace) -> tuple[dict[str, Any], int]:
control_file=args.control_file,
state_file=args.state_file,
)
if getattr(args, "pptx_report", None) is False:
pptx_report_file: str | None = None
else:
pptx_report_file = resolve_pptx_report_file(
explicit_path=getattr(args, "pptx_report_file", None),
operator_messages_file=operator_messages_file,
control_file=args.control_file,
state_file=args.state_file,
)
btw_messages_file = resolve_btw_messages_file(
explicit_path=None,
operator_messages_file=operator_messages_file,
Expand All @@ -85,6 +95,7 @@ def run_cli(args: Namespace) -> tuple[dict[str, Any], int]:
plan_overview_file=plan_overview_file,
review_summaries_dir=review_summaries_dir,
final_report_file=final_report_file,
pptx_report_file=pptx_report_file,
main_prompt_file=args.main_prompt_file,
check_commands=args.check or [],
plan_mode=args.plan_mode,
Expand Down Expand Up @@ -487,6 +498,8 @@ def on_control_command(command) -> None:
"review_summaries_dir": state_store.review_summaries_dir(),
"final_report_file": state_store.final_report_path(),
"final_report_ready": state_store.has_final_report(),
"pptx_report_file": state_store.pptx_report_path(),
"pptx_report_ready": state_store.has_pptx_report(),
"rounds": [
{
"round": item.round_index,
Expand Down
31 changes: 29 additions & 2 deletions codex_autoloop/apps/daemon_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def __init__(self, args: argparse.Namespace) -> None:
self.child_started_at: dt.datetime | None = None
self.child_control_bus: JsonlCommandBus | None = None
self.pending_attachment_batches: dict[str, list[Any]] = {}
self.pending_pptx_run_objective: str | None = None
self.pending_pptx_run_source: str | None = None
self.btw_agent = BtwAgent(
runner=self.daemon_runner,
config=BtwConfig(
Expand Down Expand Up @@ -203,6 +205,22 @@ def _build_control_channels(self) -> list[object]:

def _on_command(self, command) -> None:
self._log_event("command.received", source=command.source, kind=command.kind, text=command.text[:700])

# Handle pending PPTX confirmation reply
if self.pending_pptx_run_objective is not None:
reply = command.text.strip().lower()
if reply in ("y", "yes", "n", "no"):
objective = self.pending_pptx_run_objective
pptx_enabled = reply in ("y", "yes")
self.pending_pptx_run_objective = None
self.pending_pptx_run_source = None
self._start_child(objective, pptx_report=pptx_enabled)
return
self._send_reply(command.source, "[daemon] PPTX confirmation cancelled. Send /run again to start.")
self.pending_pptx_run_objective = None
self.pending_pptx_run_source = None
# fall through to handle the command normally

if command.kind == "help":
self._send_reply(command.source, help_text())
return
Expand Down Expand Up @@ -342,7 +360,10 @@ def _on_command(self, command) -> None:
else:
self._send_reply(command.source, "[daemon] active run exists but child control bus unavailable.")
return
self._start_child(self._maybe_rewrite_run_objective(objective, source=command.source))
# Ask about PPTX report before launching
self.pending_pptx_run_objective = objective
self.pending_pptx_run_source = command.source
self._send_reply(command.source, "Generate a PPTX run report at the end? Reply Y or N")
return
if command.kind in {"plan", "review"}:
if not self._child_running():
Expand Down Expand Up @@ -401,7 +422,7 @@ def on_complete(result) -> None:
self._send_reply(command.source, "[daemon] stopping daemon.")
self._stopping = True

def _start_child(self, objective: str) -> None:
def _start_child(self, objective: str, *, pptx_report: bool = True) -> None:
assert self.notifier is not None
timestamp = dt.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
log_path = self.logs_dir / f"run-{timestamp}.log"
Expand All @@ -428,6 +449,7 @@ def _start_child(self, objective: str) -> None:
review_summaries_dir=str(review_summaries_dir),
resume_session_id=resume_session_id,
force_new_session=force_new_session,
pptx_report=pptx_report,
)
log_file = log_path.open("w", encoding="utf-8")
self.child = subprocess.Popen(cmd, stdout=log_file, stderr=log_file, text=True, cwd=self.run_cwd)
Expand Down Expand Up @@ -613,6 +635,7 @@ def build_child_command(
review_summaries_dir: str,
resume_session_id: str | None,
force_new_session: bool = False,
pptx_report: bool = True,
) -> list[str]:
preset = get_preset(args.run_model_preset) if args.run_model_preset else None
main_model = preset.main_model if preset is not None else args.run_main_model
Expand Down Expand Up @@ -712,6 +735,10 @@ def build_child_command(
cmd.extend(["--state-file", args.run_state_file])
if args.run_no_dashboard:
cmd.append("--no-dashboard")
if pptx_report:
cmd.append("--pptx-report")
else:
cmd.append("--no-pptx-report")
for add_dir in args.run_add_dir:
cmd.extend(["--add-dir", add_dir])
for plugin_dir in args.run_plugin_dir:
Expand Down
25 changes: 25 additions & 0 deletions codex_autoloop/apps/shell_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@ def resolve_final_report_file(
)


def resolve_pptx_report_file(
*,
explicit_path: str | None,
operator_messages_file: str | None,
control_file: str | None,
state_file: str | None,
default_root: str | None = None,
) -> str:
if explicit_path:
return explicit_path
base = _resolve_artifact_dir(
operator_messages_file=operator_messages_file,
control_file=control_file,
state_file=state_file,
default_root=default_root,
)
return str(base / "run-report.pptx")


def format_control_status(state: dict[str, Any]) -> str:
status = state.get("status", "unknown")
round_index = state.get("round", 0)
Expand All @@ -125,6 +144,8 @@ def format_control_status(state: dict[str, Any]) -> str:
review_summaries_dir = state.get("review_summaries_dir")
final_report_file = state.get("final_report_file")
final_report_ready = state.get("final_report_ready")
pptx_report_file = state.get("pptx_report_file")
pptx_report_ready = state.get("pptx_report_ready")
lines = [
"[autoloop] status",
f"status={status}",
Expand All @@ -149,6 +170,10 @@ def format_control_status(state: dict[str, Any]) -> str:
lines.append(f"final_report_file={final_report_file}")
if final_report_ready is not None:
lines.append(f"final_report_ready={final_report_ready}")
if pptx_report_file:
lines.append(f"pptx_report_file={pptx_report_file}")
if pptx_report_ready is not None:
lines.append(f"pptx_report_ready={pptx_report_ready}")
return "\n".join(lines)


Expand Down
38 changes: 38 additions & 0 deletions codex_autoloop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
resolve_final_report_file,
resolve_operator_messages_file,
resolve_plan_overview_file,
resolve_pptx_report_file,
resolve_review_summaries_dir,
)

Expand All @@ -28,6 +29,7 @@
"resolve_operator_messages_file",
"resolve_plan_report_file",
"resolve_plan_todo_file",
"resolve_pptx_report_file",
"resolve_review_summaries_dir",
]

Expand Down Expand Up @@ -57,6 +59,14 @@ def main() -> None:
if args.main_prompt_file is None:
args.main_prompt_file = resolve_main_prompt_file(state_file=args.state_file, control_file=args.control_file)

# Interactive PPTX report prompt: when --pptx-report is not explicitly set
# and stdin is a terminal, ask the user before starting.
if args.pptx_report is None and _should_prompt_pptx():
args.pptx_report = _ask_pptx_report()
# --no-pptx-report explicitly disables PPTX generation.
if args.pptx_report is False:
args.pptx_report_file = None

try:
payload, exit_code = run_cli(args)
except ValueError as exc:
Expand Down Expand Up @@ -244,6 +254,17 @@ def build_parser() -> argparse.ArgumentParser:
default=None,
help="Markdown file path for the latest main prompt sent to Codex.",
)
parser.add_argument(
"--pptx-report",
action=argparse.BooleanOptionalAction,
default=None,
help="Enable/disable PPTX run report generation. When omitted, interactive runs prompt the user.",
)
parser.add_argument(
"--pptx-report-file",
default=None,
help="Output path for the auto-generated PPTX run report.",
)
parser.add_argument(
"--control-file",
default=None,
Expand Down Expand Up @@ -497,6 +518,23 @@ def resolve_main_prompt_file(*, state_file: str | None, control_file: str | None
return None


def _should_prompt_pptx() -> bool:
"""Return True when we should interactively ask about PPTX generation."""
import sys
return sys.stdin.isatty()


def _ask_pptx_report() -> bool:
"""Prompt the user to decide whether to generate a PPTX run report."""
import sys
try:
answer = input("Generate a PPTX run report at the end? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("", file=sys.stderr)
return False
return answer in ("", "y", "yes")


def _mirror_plan_report_to_todo(*, report_path: object, todo_path: str) -> None:
if not todo_path:
return
Expand Down
Loading
Loading