diff --git a/.gitignore b/.gitignore index 84b2f20..7fa74ba 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ loop-engine/state.json loop-engine/portfolio/ loop-engine/pm-reviews/ loop-engine/approvals/ +loop-engine/handoffs/ loop-engine/human-feedback/ loop-engine/evening-scorecards/* !loop-engine/evening-scorecards/.gitkeep @@ -33,3 +34,6 @@ dist/ build/ venv/ .venv/ + +# Loop runtime handoff artifacts. +.loop/daily-focus/ diff --git a/AGENTS.md b/AGENTS.md index cc97a0d..b34a45d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ ## What loop is (for you) `loop` is a daily product operating loop around a private portfolio registry and local Git repos: -`portfolio intake → portfolio verification → morning review → approval envelope → planner → value/risk gates → worker (isolated worktree) → verification (sandboxed) → reviewer → auto PR + merge → digest → evening scorecard → memory`. +`portfolio intake → portfolio verification → morning review → project-owner handoff → approval envelope → planner → value/risk gates → worker (isolated worktree) → verification (sandboxed) → reviewer → auto PR + merge → digest → evening scorecard → memory`. You operate it through the `loop` CLI (or `python3 loop-engine/bin/loopctl.py --project `). You do **not** edit the engine, prompts, or reviewer output to influence outcomes. ## First, relay these safety facts to your human @@ -27,9 +27,10 @@ Before the human starts `loop` (especially unattended), tell them, plainly: - `waiting_for_human` → `null`, or `{ "reason": , "issue_path": }` - `runs[-1].status` → `merged | no_op | needs_human | failed | ...` - **Read the recap from a file**, not by re-running digest: `loop-engine/reports//latest.md`. -- **Start with portfolio onboarding once:** `loop portfolio init`, then `loop portfolio add ...` using any handle the human has (local path, GitHub repo/URL, Linear project, URL, or plain name). `loop init` also upserts the current repo into the portfolio. +- **Start with portfolio onboarding once:** `loop portfolio init`, then `loop portfolio add ...` using any handle the human has (local path, GitHub repo/URL, Linear project, URL, or plain name). If the human has a dedicated project conversation/session, capture it with `--owner-thread-name`, `--owner-thread-id`, `--owner-mode`, and `--handoff-policy`. `loop init` also upserts the current repo into the portfolio. - **Run CTO catch-up for the portfolio:** `loop portfolio intake [project...]` writes `loop-engine/portfolio//profile.{md,json}` with end goal, current stage/progress, primary artifacts, verification candidates, readiness blockers, next steps, and task-level risk boundaries. Portfolio stages are fixed to `idea`, `building_mvp`, `mvp_released`, `released_v1`, and `iterating`; do not invent new stage labels. -- **Start each day with PM review:** `loop morning` refreshes portfolio intake profiles, shows Portfolio Registry Verification and Portfolio Readiness boards, then runs the PM Review Agent and writes `pm-reviews/YYYY-MM-DD.{md,json}`; `loop approve ` writes the project's `.loop/daily-focus/latest.md`. +- **Start each day with PM review:** `loop morning` refreshes portfolio intake profiles, shows Portfolio Registry Verification and Portfolio Readiness boards, then runs the PM Review Agent and writes `pm-reviews/YYYY-MM-DD.{md,json}` plus `handoffs/YYYY-MM-DD/.md`; `loop approve ` writes the project's `.loop/daily-focus/latest.md`. +- **Keep the Secretary/PM thread clean:** after morning review, use `loop handoff [project...]` or read `loop-engine/handoffs/latest/.md`, then move execution commands and debug detail to that project's owner thread. The PM thread should keep only portfolio decisions, approvals, shipped value, PR/issue links, digest paths, and `waiting_for_human` summaries. - **Do not ignore non-ready high-value projects:** if a local Git repo lacks `.loop/contract.yaml`, clean baseline, or verification commands, surface readiness work. `init-loop` is a required readiness gate for eligible local Git repos, not a soft suggestion; normalize `blocked_needs_loop_init` projects back to `decision: "init-loop"` even if the PM agent tries to mark them `plan-only` or `hold`. `loop approve --init-loop` is the explicit mutation path for approved loop bootstrap; `loop approve --all-init-loop` applies the latest morning review's required init-loop projects, and `loop portfolio init-loop --all-eligible` bootstraps every eligible local Git repo during onboarding. After any init-loop approval, rerun `loop morning` before execution. - **Approve medium risk once per day:** when morning recommends a bounded envelope, `loop approve --approve-medium` approves all same-day medium-risk items that stay inside that envelope. Do not approve medium risk item-by-item inside each cycle. - **Start approved projects only:** `loop start-day` reads `approvals/latest.json`; it refuses projects not approved today. @@ -79,7 +80,8 @@ commands: portfolio_stages: { allowed: [idea, building_mvp, mvp_released, released_v1, iterating], default_progress_percent: { idea: 10, building_mvp: 35, mvp_released: 60, released_v1: 80, iterating: 90 } } setup: { in: "operator machine", out: "~/.config/loop/config.json + missing-action prompts" } init: { in: "product repo cwd", out: ".loop/contract.yaml + registry + pilot branch", fail: "LOOP_BLOCKED " } - morning: { in: "portfolio registry + registered project snapshots", out: "portfolio verification board + pm-reviews/YYYY-MM-DD.{md,json} + latest.{md,json}, PM-agent value-ranked portfolio board" } + morning: { in: "portfolio registry + registered project snapshots", out: "portfolio verification board + pm-reviews/YYYY-MM-DD.{md,json} + latest.{md,json}, PM-agent value-ranked portfolio board + handoffs/YYYY-MM-DD/.md" } + handoff: { in: "latest morning PM review [project...]", out: "handoffs/YYYY-MM-DD/index.{md,json} + handoffs/YYYY-MM-DD/.md + latest mirrors; owner-thread prompt for isolated execution context" } approve: { in: "project [--approve-medium | --medium-envelope ... | --init-loop]", out: ".loop/daily-focus/latest.md + approvals/latest.json, or approved loop bootstrap for readiness work" } reject: { in: "project", out: "approvals/latest.json rejection record" } start-day: { in: "today's approved projects", out: "approved loops active; medium first run supervised" } @@ -127,6 +129,7 @@ runtime_artifacts: # git-ignored; never commit to a public repo runs: loop-engine/runs// pm_review: loop-engine/pm-reviews/latest.md pm_review_plan: loop-engine/pm-reviews/latest.json + handoffs: loop-engine/handoffs/latest/.md portfolio: ~/.config/loop/portfolio.json portfolio_profiles: loop-engine/portfolio//profile.{md,json} approvals: loop-engine/approvals/latest.json diff --git a/README.md b/README.md index 08811d9..bc6a088 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ loop doctor # checks gh / sandbox-exec / the agent CLI you # 4) First onboarding: create your portfolio registry loop portfolio init loop portfolio add /path/to/your-product-repo +loop portfolio add /path/to/owner-product \ + --owner-thread-name "Owner thread name" --owner-mode codex_thread loop portfolio add https://github.com/owner/repo --mode plan-only loop portfolio add --linear-project "Newsletter" --mode hold loop portfolio status # private source of truth for daily PM review @@ -124,6 +126,8 @@ loop status # 6) Morning PM review across the portfolio loop morning # starts with a Portfolio Registry Verification board, # then writes pm-reviews/YYYY-MM-DD.{md,json} + # and handoffs/YYYY-MM-DD/.md +loop handoff # prints the owner-thread execution handoff loop approve # approve low-risk automatic work only # To approve every medium-risk item today inside the PM-recommended envelope: loop approve --approve-medium @@ -166,7 +170,14 @@ By default **only the top-ranked auto-runnable task executes per cycle** (`max_t ## Daily rhythm: morning & evening / 每日节奏 -`loop` now owns the full daily routine: **portfolio verification → morning review → approvals → day loop → evening recap**. The loop still keeps the human in charge of direction: morning review proposes and ranks work; only `loop approve` turns it into execution input. +`loop` now owns the full daily routine: **portfolio verification → morning review → project-owner handoff → approvals → day loop → evening recap**. The loop still keeps the human in charge of direction: morning review proposes and ranks work; only `loop approve` turns it into execution input. + +The recommended operating model is a clean split between: + +- **Secretary/PM thread**: portfolio review, value ranking, approvals, evening recap, and only short product-level status. +- **Project owner thread**: project execution, loop commands, PR/debug details, and `waiting_for_human` handling for one product. + +Morning review writes durable handoff prompts so the Secretary/PM thread does not get polluted by worker logs, diffs, install noise, or project-specific debugging. **First onboarding — build the private portfolio registry once:** 1. `loop portfolio init` creates `~/.config/loop/portfolio.json`. @@ -175,6 +186,7 @@ By default **only the top-ranked auto-runnable task executes per cycle** (`max_t - GitHub repo or URL: `loop portfolio add owner/repo` or `loop portfolio add https://github.com/owner/repo` - Linear project: `loop portfolio add --linear-project "Project Name"` - plain product name or URL for early ideas + - optional owner routing: `--owner-thread-name "TokenPulse owner" --owner-thread-id --owner-mode codex_thread` 3. `loop init` also upserts the current repo into the portfolio as an executable `loop` project. 4. `loop portfolio intake [project...]` performs the CTO catch-up pass for each portfolio entry: where the project lives, what it appears to be building, current stage/progress, primary artifact candidates, verification candidates, loop readiness, blockers, next steps, and task-level risk boundaries. It writes: - `loop-engine/portfolio//profile.md` @@ -203,23 +215,29 @@ For local Git repos, `init-loop` is a required readiness gate, not a soft sugges - `loop-engine/pm-reviews/latest.md` - `loop-engine/pm-reviews/YYYY-MM-DD.json` - `loop-engine/pm-reviews/latest.json` -2. `loop approve ` writes that project's approved focus to: + - `loop-engine/handoffs/YYYY-MM-DD/index.md` + - `loop-engine/handoffs/YYYY-MM-DD/.md` + - `loop-engine/handoffs/latest/.md` +2. `loop handoff [project...]` rebuilds and prints the latest handoff index. Send each `.md` to that project's owner thread. The handoff contains the day's objective, expected user value, risk envelope, value-ranked work, exact commands, and a reporting contract. +3. `loop approve ` writes that project's approved focus to: - `/.loop/daily-focus/YYYY-MM-DD.md` - `/.loop/daily-focus/latest.md` - `loop-engine/approvals/YYYY-MM-DD.{json,md}` -3. Medium-risk work is approved once in the morning, for the whole day, inside a bounded envelope: +4. Medium-risk work is approved once in the morning, for the whole day, inside a bounded envelope: - recommended: `loop approve --approve-medium` - `loop approve --medium-envelope --allowed-file ... --verification-command ...` - PM-recommended verification commands are clipped to the project's trusted `verification_commands`; untrusted suggestions are shown in the morning review but are not written into the approved envelope. -4. Readiness work can be the highest-value work. If an important local Git repo is not loop-ready, morning review treats loop init as a required setup gate: define its artifact contract, verification commands, and baseline, then explicitly approve loop init with `loop approve --init-loop`. That command mutates the repo by running loop bootstrap, then asks you to run morning review again before execution. +5. Readiness work can be the highest-value work. If an important local Git repo is not loop-ready, morning review treats loop init as a required setup gate: define its artifact contract, verification commands, and baseline, then explicitly approve loop init with `loop approve --init-loop`. That command mutates the repo by running loop bootstrap, then asks you to run morning review again before execution. - To approve every `init-loop` project recommended by the latest morning review: `loop approve --all-init-loop` - During first onboarding, to initialize every eligible local Git repo in the portfolio: `loop portfolio init-loop --all-eligible` **Day — execute only approved work:** -1. `loop start-day [project...]` starts approved projects only. -2. Low-risk work may run unattended after value/verification gates. -3. Medium-risk work may run unattended only when it matches the morning-approved envelope. The first medium-risk execution runs with `supervised=true`, then continues hourly only if that first cycle does not leave `waiting_for_human`. -4. Budgets and stop rules come from daily focus: `recommended_cycles`, `stop_condition`, `value_threshold`, `max_noop_cycles`. +1. Run execution commands from the project owner thread, using `loop-engine/handoffs/latest/.md` as the prompt. +2. `loop start-day [project...]` starts approved projects only. +3. Low-risk work may run unattended after value/verification gates. +4. Medium-risk work may run unattended only when it matches the morning-approved envelope. The first medium-risk execution runs with `supervised=true`, then continues hourly only if that first cycle does not leave `waiting_for_human`. +5. Budgets and stop rules come from daily focus: `recommended_cycles`, `stop_condition`, `value_threshold`, `max_noop_cycles`. +6. The owner thread reports back only shipped value, PR/issue links, digest/report path, `waiting_for_human` items, and a continue/stop/move recommendation. **Evening — stop, recap, score:** 1. `loop evening [project...]` pauses the named projects. With no project args, it pauses **all active registered loops**, even loops that were started manually outside the approval flow. @@ -270,6 +288,7 @@ Risk is task-level, not project-level. A trading project is not automatically hi Quick orientation for an operating agent: - **Read state, don't poll commands:** `loop status --json`, or read `loop-engine/state.json` directly. Key fields: `loop_job.state` (`active|paused|stopped`), `current_phase`, `waiting_for_human` (`{reason, issue_path}` or null), `runs[-1].status`. +- **Keep Secretary/PM context clean:** after `loop morning`, read `loop-engine/handoffs/latest/.md` and move execution to that project owner thread. Report back only product-level outcome and links. - **Recap is a file:** read `loop-engine/reports//latest.md`; don't call `loop digest` in a loop. - **Escalate, don't bypass:** anything in `waiting_for_human` needs a human decision (see the reason-code table in [AGENTS.md](AGENTS.md) and [Troubleshooting](#troubleshooting--排错)). Never edit prompts/reviewer output to force a pass. @@ -281,6 +300,7 @@ Quick orientation for an operating agent: - [examples/portfolio.example.json](examples/portfolio.example.json) — private portfolio registry shape (auto-written under `~/.config/loop/portfolio.json`) - [examples/portfolio-profile.example.json](examples/portfolio-profile.example.json) — CTO intake profile shape (auto-written by `loop portfolio intake`) - `loop-engine/portfolio//profile.{md,json}` — private CTO intake profiles (auto-written by `loop portfolio intake`) +- `loop-engine/handoffs//.md` — owner-thread execution prompt generated by `loop morning` / `loop handoff` - [examples/contract.example.yaml](examples/contract.example.yaml) — the `.loop/contract.yaml` schema - [examples/daily-focus.example.md](examples/daily-focus.example.md) — daily focus + preapproved envelope @@ -364,7 +384,7 @@ Morning/day-start `LOOP_BLOCKED` reasons: `portfolio_missing` means run `loop po ## Runtime artifacts / 运行产物 -Git-ignored by design — they hold local paths, private strategy, and agent transcripts. **Keep them out of public repos:** `loop-engine/runs/`, `reports/`, `registry.json`, `state.json`, `pm-reviews/`, `approvals/`, `evening-scorecards/`, `human-feedback/`, `knowledge/`, `logs/`, `locks/`, `worktrees/`. +Git-ignored by design — they hold local paths, private strategy, and agent transcripts. **Keep them out of public repos:** `loop-engine/runs/`, `reports/`, `registry.json`, `state.json`, `pm-reviews/`, `approvals/`, `handoffs/`, `evening-scorecards/`, `human-feedback/`, `knowledge/`, `logs/`, `locks/`, `worktrees/`. ## Repository layout / 仓库结构 diff --git a/examples/portfolio.example.json b/examples/portfolio.example.json index 05dbfe7..f78b5d0 100644 --- a/examples/portfolio.example.json +++ b/examples/portfolio.example.json @@ -11,6 +11,12 @@ "github_repo": "operator/tokenpulse", "linear_project": "TokenPulse", "loop_project_id": "tokenpulse" + }, + "owner_thread": { + "id": "thread-tokenpulse-owner", + "name": "TokenPulse owner thread", + "mode": "codex_thread", + "handoff_policy": "manual_copy" } }, "newsletter": { diff --git a/loop-engine/bin/loop_facade.py b/loop-engine/bin/loop_facade.py index 0f5de71..7c8bc14 100755 --- a/loop-engine/bin/loop_facade.py +++ b/loop-engine/bin/loop_facade.py @@ -18,6 +18,7 @@ def usage(code: int = 2) -> int: " /loop doctor [project]\n" " /loop portfolio init|add|status|intake|init-loop [project|--all-eligible]\n" " /loop morning [project...]\n" + " /loop handoff [project...] [--json]\n" " /loop approve [project] [--approve-medium | --medium-envelope NAME | --init-loop | --all-init-loop]\n" " /loop reject [project]\n" " /loop start-day [project...]\n" @@ -113,10 +114,10 @@ def main() -> int: loopctl_args = [command] + passthrough if project: loopctl_args += ["--project", project] - elif command in {"morning", "start-day", "evening"}: + elif command in {"morning", "start-day", "evening", "handoff"}: loopctl_args = [command] for arg in rest: - if arg == "--start": + if arg in {"--start", "--json"}: loopctl_args.append(arg) else: loopctl_args += ["--project", arg] diff --git a/loop-engine/bin/loopctl.py b/loop-engine/bin/loopctl.py index dcb1278..b3a03b4 100755 --- a/loop-engine/bin/loopctl.py +++ b/loop-engine/bin/loopctl.py @@ -1218,6 +1218,10 @@ def infer_portfolio_entry( url: str | None = None, mode: str | None = None, default_review: bool = True, + owner_thread_id: str | None = None, + owner_thread_name: str | None = None, + owner_mode: str | None = None, + handoff_policy: str | None = None, ) -> dict: handles: dict[str, str] = {} raw_handle = (handle or "").strip() @@ -1275,12 +1279,54 @@ def infer_portfolio_entry( "default_review": bool(default_review), "handles": handles, } + owner_thread = normalize_owner_thread({ + "id": owner_thread_id, + "name": owner_thread_name, + "mode": owner_mode, + "handoff_policy": handoff_policy, + }) + if owner_thread: + entry["owner_thread"] = owner_thread if handles.get("loop_project_id"): entry["id"] = str(handles["loop_project_id"]) entry["mode"] = "loop" return entry +def normalize_owner_thread(raw: object) -> dict: + if not isinstance(raw, dict): + return {} + owner_id = str(raw.get("id") or raw.get("thread_id") or "").strip() + name = str(raw.get("name") or raw.get("thread_name") or "").strip() + mode = str(raw.get("mode") or raw.get("owner_mode") or "").strip().lower() + handoff_policy = str(raw.get("handoff_policy") or raw.get("policy") or "").strip().lower() + if mode not in {"manual", "codex_thread", "external"}: + mode = "codex_thread" if owner_id else "manual" if name else "" + if handoff_policy not in {"manual_copy", "codex_dispatch", "external"}: + handoff_policy = "manual_copy" if (owner_id or name or mode) else "" + normalized = {} + if owner_id: + normalized["id"] = owner_id + if name: + normalized["name"] = name + if mode: + normalized["mode"] = mode + if handoff_policy: + normalized["handoff_policy"] = handoff_policy + return normalized + + +def portfolio_owner_thread(entry: dict) -> dict: + owner = normalize_owner_thread(entry.get("owner_thread")) + if owner: + return owner + metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {} + owner = normalize_owner_thread(metadata.get("owner_thread")) + if owner: + return owner + return {} + + def portfolio_project_readiness(entry: dict) -> str: handles = entry.get("handles") or {} if handles.get("loop_project_id"): @@ -1305,6 +1351,7 @@ def portfolio_entry_to_row(entry: dict) -> dict: handles = entry.get("handles") or {} project = entry.get("id") or portfolio_entry_id(entry) profile = load_portfolio_profile(str(project)) + owner = portfolio_owner_thread(entry) return { "project": project, "name": entry.get("name") or portfolio_entry_id(entry), @@ -1315,6 +1362,10 @@ def portfolio_entry_to_row(entry: dict) -> dict: "linear_project": handles.get("linear_project") or "-", "url": handles.get("url") or "-", "loop_project_id": handles.get("loop_project_id") or "-", + "owner_thread_id": owner.get("id") or "-", + "owner_thread_name": owner.get("name") or "-", + "owner_mode": owner.get("mode") or "-", + "handoff_policy": owner.get("handoff_policy") or "-", "readiness": portfolio_project_readiness(entry), "profile_status": "present" if profile else "missing", } @@ -3983,6 +4034,229 @@ def pm_review_paths() -> dict[str, Path]: } +def handoff_paths(date: str | None = None) -> dict[str, Path]: + handoff_date = date or today_date() + root = ENGINE_ROOT / "handoffs" / handoff_date + return { + "dir": root, + "index_json": root / "index.json", + "index_md": root / "index.md", + "latest_dir": ENGINE_ROOT / "handoffs" / "latest", + } + + +def handoff_project_path(project: str, date: str | None = None) -> Path: + return handoff_paths(date)["dir"] / f"{slugify_project_id(project)}.md" + + +def handoff_owner_label(row: dict) -> str: + portfolio = row.get("portfolio") or {} + name = portfolio.get("owner_thread_name") + owner_id = portfolio.get("owner_thread_id") + mode = portfolio.get("owner_mode") + parts = [] + if name and name != "-": + parts.append(str(name)) + if owner_id and owner_id != "-": + parts.append(str(owner_id)) + if mode and mode != "-": + parts.append(f"mode={mode}") + return " / ".join(parts) if parts else "unassigned" + + +def handoff_status_for_project(project: str) -> dict: + approvals = load_latest_approvals() + approved = approvals.get("approved") or {} + rejected = approvals.get("rejected") or {} + if project in rejected: + return {"status": "rejected", "record": rejected[project]} + if project in approved: + record = approved[project] + if record.get("readiness_action") == "init_loop": + return {"status": "approved_init_loop_only", "record": record} + return {"status": "approved", "record": record} + return {"status": "not_approved", "record": {}} + + +def handoff_commands_for_row(row: dict, approval_status: str) -> list[str]: + project = row["project"] + decision = row.get("decision") + if approval_status == "rejected": + return [] + if decision == "init-loop": + return [ + f"loop approve --project {project} --init-loop", + "loop morning", + ] + if decision == "read-only": + return [ + f"loop digest --project {project}", + ] + if decision == "loop": + commands = [] + medium = row.get("medium_envelope") + if medium: + commands.append(f"loop approve --project {project} --approve-medium") + commands.append("loop start-day --project " + project) + commands.append(f"loop run-now --project {project} --supervised") + else: + commands.append(f"loop approve --project {project}") + commands.append("loop start-day --project " + project) + commands.append(f"loop run-now --project {project}") + return commands + return [] + + +def render_project_handoff(row: dict, approval_status: dict) -> str: + portfolio = row.get("portfolio") or {} + profile = row.get("portfolio_profile") or {} + medium = row.get("medium_envelope") or {} + commands = handoff_commands_for_row(row, approval_status["status"]) + owner_label = handoff_owner_label(row) + progress = profile.get("progress_percent") + progress_display = f"{progress}%" if progress is not None else "-" + lines = [ + f"# Project Handoff: {row['project']}", + "", + f"Date: {today_date()}", + f"Owner thread: {owner_label}", + f"Approval status: {approval_status['status']}", + "", + "## Secretary Decision", + "", + f"- Decision: `{row.get('decision')}`", + f"- Today objective: {row.get('today_focus')}", + f"- Top value task: {row.get('top_value_task')}", + f"- Expected user/operator value: {row.get('user_benefit')}", + f"- Success criteria: {row.get('success_criteria')}", + f"- Recommended cycles: `{row.get('recommended_cycles')}`", + f"- Stop condition: {row.get('stop_condition')}", + f"- Value threshold: `{row.get('value_threshold')}`", + "", + "## Project Handles", + "", + f"- Local path: `{portfolio.get('local_path') or '-'}`", + f"- GitHub: `{portfolio.get('github_repo') or '-'}`", + f"- Linear: `{portfolio.get('linear_project') or '-'}`", + f"- Loop project id: `{portfolio.get('loop_project_id') or '-'}`", + f"- Stage: `{profile.get('current_stage') or '-'}`", + f"- Progress: `{progress_display}`", + "", + "## Risk And Approval Envelope", + "", + f"- Top risk: `{row.get('top_risk')}`", + f"- Approval path: {row.get('approval_needed')}", + ] + if medium: + lines.extend([ + f"- Medium envelope: `{medium.get('name')}`", + f"- Scope: {medium.get('scope')}", + "- Allowed files:", + ]) + lines.extend(f" - `{item}`" for item in (medium.get("allowed_files") or [])) + lines.append("- Verification commands:") + lines.extend(f" - `{item}`" for item in (medium.get("verification_commands") or [])) + forbidden = medium.get("forbidden_changes") or [] + if forbidden: + lines.append("- Forbidden changes:") + lines.extend(f" - {item}" for item in forbidden) + else: + lines.append("- Medium envelope: `none`") + lines.extend([ + "", + "## Value-Ranked Work", + "", + ]) + for task in row.get("tasks") or []: + lines.extend([ + f"{task['rank']}. {task['task']}", + f" - value_score: `{task['value_score']}`", + f" - risk: `{task['risk']}`", + f" - approval_path: {task['approval_path']}", + f" - benefit: {task['benefit']}", + ]) + lines.extend([ + "", + "## Commands For Owner Thread", + "", + ]) + if commands: + lines.append("Run these from the project owner thread, not from the Secretary/PM thread:") + lines.append("") + lines.append("```sh") + lines.extend(commands) + lines.append("```") + elif approval_status["status"] == "rejected": + lines.append("No execution command. The Secretary/PM thread rejected this project for today.") + else: + lines.append("No execution command. This project is plan-only, blocked, hold, or needs more portfolio identity.") + lines.extend([ + "", + "## Reporting Contract", + "", + "The owner thread should report back to the Secretary/PM thread with only:", + "", + "- shipped user-visible value", + "- PR or issue links", + "- digest/report path", + "- waiting_for_human items", + "- whether to continue, stop, or move to another project", + "", + "Do not paste worker logs, long test output, raw diffs, or dependency install noise into the Secretary/PM thread.", + ]) + return "\n".join(lines).rstrip() + "\n" + + +def write_handoff_files(plan: dict, projects: list[str] | None = None) -> dict: + rows = plan.get("projects") if isinstance(plan.get("projects"), list) else [] + selected = set(projects or []) + if selected: + rows = [row for row in rows if row.get("project") in selected] + paths = handoff_paths(str(plan.get("date") or today_date())) + paths["dir"].mkdir(parents=True, exist_ok=True) + paths["latest_dir"].mkdir(parents=True, exist_ok=True) + items: list[dict] = [] + for row in rows: + if not isinstance(row, dict) or not row.get("project"): + continue + project = str(row["project"]) + status = handoff_status_for_project(project) + path = handoff_project_path(project, str(plan.get("date") or today_date())) + text = render_project_handoff(row, status) + path.write_text(text) + latest_path = paths["latest_dir"] / path.name + latest_path.write_text(text) + items.append({ + "project": project, + "decision": row.get("decision"), + "approval_status": status["status"], + "owner_thread": { + "id": (row.get("portfolio") or {}).get("owner_thread_id") or "-", + "name": (row.get("portfolio") or {}).get("owner_thread_name") or "-", + "mode": (row.get("portfolio") or {}).get("owner_mode") or "-", + "handoff_policy": (row.get("portfolio") or {}).get("handoff_policy") or "-", + }, + "handoff": str(path), + "latest": str(latest_path), + }) + index = {"date": str(plan.get("date") or today_date()), "handoffs": items} + write_json(paths["index_json"], index) + latest_index = paths["latest_dir"] / "index.json" + write_json(latest_index, index) + lines = [f"# Project Handoffs - {index['date']}", ""] + if not items: + lines.append("- None") + for item in items: + lines.append( + f"- `{item['project']}` decision=`{item['decision']}` " + f"approval=`{item['approval_status']}` owner={display_text(item['owner_thread'].get('name') or '-')} " + f"handoff={item['handoff']}" + ) + paths["index_md"].write_text("\n".join(lines).rstrip() + "\n") + (paths["latest_dir"] / "index.md").write_text(paths["index_md"].read_text()) + return index + + def safe_repo_file_snapshot(repo_path: Path, rel_path: str, max_chars: int = 3000) -> str: path = repo_path / rel_path if not path.exists(): @@ -4467,8 +4741,8 @@ def render_morning_review(rows: list[dict], summary: str = "", questions: list[s "", "Verify this is the full portfolio before approving today's loops. Add missing projects with `loop portfolio add ...`.", "", - "| Project | Mode | Default Review | Readiness | Local Path | GitHub | Linear | URL |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", + "| Project | Mode | Default Review | Readiness | Owner Thread | Local Path | GitHub | Linear | URL |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", ] for row in rows: portfolio = row.get("portfolio") or {} @@ -4477,6 +4751,7 @@ def render_morning_review(rows: list[dict], summary: str = "", questions: list[s f"{display_text(portfolio.get('mode') or row.get('decision'))} | " f"{display_text(portfolio.get('default_review', True))} | " f"{display_text(row.get('readiness') or portfolio.get('readiness') or '-')} | " + f"{display_text(portfolio.get('owner_thread_name') or '-')} | " f"{display_text(portfolio.get('local_path') or '-')} | " f"{display_text(portfolio.get('github_repo') or '-')} | " f"{display_text(portfolio.get('linear_project') or '-')} | " @@ -4578,6 +4853,19 @@ def render_morning_review(rows: list[dict], summary: str = "", questions: list[s f"| {display_text(row['project'])} | {row['recommended_cycles']} | " f"{display_text(row['stop_condition'], max_chars=220)} | {row['value_threshold']} |" ) + lines.extend([ + "", + "## Project Owner Handoffs", + "", + "These files are the prompts to send to project owner threads. Use them instead of pasting execution detail into the Secretary/PM thread.", + "", + f"- Handoff index: `{handoff_paths()['index_md']}`", + ]) + for row in rows: + lines.append( + f"- `{display_text(row['project'])}` → `{handoff_project_path(row['project'])}` " + f"(owner: {display_text(handoff_owner_label(row))})" + ) lines.extend([ "", "## Approved Loop Starts", @@ -4685,6 +4973,8 @@ def write_morning_review(projects: list[str] | None = None) -> dict: pm_agent_config([row["project"] for row in baseline_rows]), ) plan = normalize_pm_plan(load_agent_pm_plan(paths["agent_plan"]), baseline_rows) + handoff_index = write_handoff_files(plan) + plan["handoffs"] = handoff_index.get("handoffs") or [] text = render_morning_review( plan["projects"], summary=plan.get("summary", ""), @@ -4861,6 +5151,10 @@ def portfolio_add_command( url: str | None, mode: str | None, default_review: bool, + owner_thread_id: str | None = None, + owner_thread_name: str | None = None, + owner_mode: str | None = None, + handoff_policy: str | None = None, ) -> None: entry = infer_portfolio_entry( handle, @@ -4871,6 +5165,10 @@ def portfolio_add_command( url=url, mode=mode, default_review=default_review, + owner_thread_id=owner_thread_id, + owner_thread_name=owner_thread_name, + owner_mode=owner_mode, + handoff_policy=handoff_policy, ) saved = upsert_portfolio_entry(entry) row = portfolio_entry_to_row(saved) @@ -4917,7 +5215,8 @@ def portfolio_status_command(json_output: bool = False) -> None: print( f"PORTFOLIO_PROJECT {row['project']} mode={row['mode']} " f"review={str(row['default_review']).lower()} readiness={row['readiness']} " - f"path={row['local_path']} github={row['github_repo']} linear={row['linear_project']}" + f"path={row['local_path']} github={row['github_repo']} linear={row['linear_project']} " + f"owner={row['owner_thread_name']}" ) @@ -5082,6 +5381,10 @@ def portfolio_command(args: argparse.Namespace) -> None: args.url, args.mode, not args.no_review, + args.owner_thread_id, + args.owner_thread_name, + args.owner_mode, + args.handoff_policy, ) elif args.portfolio_command == "status": portfolio_status_command(json_output=args.json) @@ -5162,6 +5465,8 @@ def morning_command(projects: list[str] | None, start_after: bool = False) -> No result = write_morning_review(projects) print(f"MORNING_REVIEW {result['paths']['dated']}") print(f"MORNING_REVIEW_LATEST {result['paths']['latest']}") + handoff_index = handoff_paths()["index_md"] + print(f"MORNING_HANDOFFS {handoff_index}") for row in result["rows"]: print( f"MORNING_PROJECT {row['project']} value={row['tasks'][0]['value_score']} " @@ -5171,6 +5476,26 @@ def morning_command(projects: list[str] | None, start_after: bool = False) -> No start_day_command([row["project"] for row in result["rows"]]) +def handoff_command(projects: list[str] | None = None, as_json: bool = False) -> None: + plan = latest_pm_plan() + if not plan: + raise LoopBlocked( + "missing_morning_review", + "No current morning PM review found. Run `loop morning` before generating handoffs.", + {"next_actions": ["loop morning"]}, + ) + index = write_handoff_files(plan, projects=projects) + if as_json: + print(json.dumps(index, ensure_ascii=False, indent=2)) + return + print(f"HANDOFF_INDEX {handoff_paths(str(plan.get('date') or today_date()))['index_md']}") + for item in index.get("handoffs") or []: + print( + f"HANDOFF_PROJECT {item['project']} decision={item['decision']} " + f"approval={item['approval_status']} file={item['handoff']}" + ) + + def approve_command( project: str | None, cwd: Path, @@ -6274,6 +6599,10 @@ def main() -> int: portfolio_add.add_argument("--url") portfolio_add.add_argument("--mode", choices=sorted(PORTFOLIO_MODES), default="plan-only") portfolio_add.add_argument("--no-review", action="store_true") + portfolio_add.add_argument("--owner-thread-id") + portfolio_add.add_argument("--owner-thread-name") + portfolio_add.add_argument("--owner-mode", choices=["manual", "codex_thread", "external"]) + portfolio_add.add_argument("--handoff-policy", choices=["manual_copy", "codex_dispatch", "external"]) portfolio_status = portfolio_sub.add_parser("status") portfolio_status.add_argument("--json", action="store_true") portfolio_intake = portfolio_sub.add_parser("intake") @@ -6286,6 +6615,9 @@ def main() -> int: morning_parser = sub.add_parser("morning") morning_parser.add_argument("--project", action="append") morning_parser.add_argument("--start", action="store_true") + handoff_parser = sub.add_parser("handoff") + handoff_parser.add_argument("--project", action="append") + handoff_parser.add_argument("--json", action="store_true") approve_parser = sub.add_parser("approve") approve_parser.add_argument("--project") approve_parser.add_argument("--approve-medium", action="store_true") @@ -6369,6 +6701,8 @@ def main() -> int: portfolio_command(args) elif args.command == "morning": morning_command(args.project, start_after=args.start) + elif args.command == "handoff": + handoff_command(args.project, as_json=args.json) elif args.command == "approve": approve_command( args.project, diff --git a/loop-engine/prompts/pm_review.md b/loop-engine/prompts/pm_review.md index ea382c0..f352dde 100644 --- a/loop-engine/prompts/pm_review.md +++ b/loop-engine/prompts/pm_review.md @@ -63,6 +63,11 @@ Important product rules: scope, forbidden_changes. - External PM skill packages are recommended but not required. Use the product management reasoning embedded in this prompt and the provided snapshots. +- The Secretary/PM thread is for portfolio decisions, not execution detail. + Treat any `owner_thread` metadata as routing information for a separate + project owner conversation. Your plan should be handoff-ready: clear enough + that a project owner thread can execute it without dragging worker logs, + diffs, or debugging noise back into the PM thread. Write exactly these files: - {{REVIEW_DIR}}/pm-analysis.md diff --git a/loop-engine/tests/test_daily_routine.py b/loop-engine/tests/test_daily_routine.py index aff5c72..4d6fee2 100644 --- a/loop-engine/tests/test_daily_routine.py +++ b/loop-engine/tests/test_daily_routine.py @@ -193,12 +193,29 @@ def fake_run(cmd): assert loop_facade.main() == 0 assert captured["cmd"][-3:] == ["portfolio", "init-loop", "--all-eligible"] + monkeypatch.setattr(sys, "argv", ["loop", "handoff", "demo", "--json"]) + assert loop_facade.main() == 0 + assert captured["cmd"][-4:] == ["handoff", "--project", "demo", "--json"] + def test_portfolio_add_accepts_multiple_handle_types(monkeypatch, tmp_path, capsys): repo = make_repo(tmp_path) patch_engine(monkeypatch, tmp_path, repo) - loopctl.portfolio_add_command(str(repo), "Local Demo", None, None, None, None, "plan-only", True) + loopctl.portfolio_add_command( + str(repo), + "Local Demo", + None, + None, + None, + None, + "plan-only", + True, + "thread-123", + "Local Demo Owner", + "codex_thread", + "manual_copy", + ) loopctl.portfolio_add_command("https://github.com/acme/app", None, None, None, None, None, "read-only", True) loopctl.portfolio_add_command(None, None, None, None, "Content Pipeline", None, "hold", False) @@ -210,6 +227,10 @@ def test_portfolio_add_accepts_multiple_handle_types(monkeypatch, tmp_path, caps } assert "PORTFOLIO_ADDED" in output assert rows["local-demo"]["local_path"] == str(repo) + assert rows["local-demo"]["owner_thread_id"] == "thread-123" + assert rows["local-demo"]["owner_thread_name"] == "Local Demo Owner" + assert rows["local-demo"]["owner_mode"] == "codex_thread" + assert rows["local-demo"]["handoff_policy"] == "manual_copy" assert rows["app"]["github_repo"] == "acme/app" assert rows["content-pipeline"]["linear_project"] == "Content Pipeline" assert rows["content-pipeline"]["default_review"] is False @@ -259,6 +280,12 @@ def test_portfolio_profile_uses_standard_stage_defaults(monkeypatch, tmp_path): "local_path": str(repo), "github_repo": "owner/demo", }, + "owner_thread": { + "id": "thread-demo", + "name": "Demo Owner", + "mode": "codex_thread", + "handoff_policy": "manual_copy", + }, }, "early-idea": { "id": "early-idea", @@ -298,6 +325,12 @@ def test_portfolio_profile_respects_stage_and_progress_override(monkeypatch, tmp "local_path": str(repo), "github_repo": "owner/demo", }, + "owner_thread": { + "id": "thread-demo", + "name": "Demo Owner", + "mode": "codex_thread", + "handoff_policy": "manual_copy", + }, }, }, }) @@ -653,6 +686,12 @@ def test_morning_review_includes_portfolio_verification_board(monkeypatch, tmp_p "local_path": str(repo), "github_repo": "owner/demo", }, + "owner_thread": { + "id": "thread-demo", + "name": "Demo Owner", + "mode": "codex_thread", + "handoff_policy": "manual_copy", + }, }, "newsletter": { "id": "newsletter", @@ -673,10 +712,12 @@ def test_morning_review_includes_portfolio_verification_board(monkeypatch, tmp_p text = result["markdown"] assert "Portfolio Registry Verification" in text assert "Verify this is the full portfolio" in text - assert "| demo | loop | True | executable |" in text - assert "| newsletter | plan-only | True | pm_only_missing_local_path |" in text + assert "| demo | loop | True | executable | Demo Owner |" in text + assert "| newsletter | plan-only | True | pm_only_missing_local_path | - |" in text + assert "Project Owner Handoffs" in text snapshot = json.loads(result["paths"]["snapshot"].read_text()) assert [row["project"] for row in snapshot["portfolio_registry"]] == ["demo", "newsletter"] + assert snapshot["portfolio_registry"][0]["owner_thread_name"] == "Demo Owner" assert result["plan"]["projects"][0]["project"] == "demo" assert any(row["project"] == "newsletter" for row in result["plan"]["projects"]) @@ -695,13 +736,39 @@ def test_morning_review_writes_cross_project_board(monkeypatch, tmp_path): assert "Ship a visible calculator input flow" in text assert "loop approve demo --approve-medium" in text assert "Ranked Development Tasks" in text + assert "Project Owner Handoffs" in text assert "demo" in text assert calls and "PM Review Agent" in calls[0]["prompt"] plan = json.loads((engine / "pm-reviews" / "latest.json").read_text()) assert plan["projects"][0]["medium_envelope"]["name"] == "primary-surface" + assert plan["handoffs"][0]["project"] == "demo" + handoff = engine / "handoffs" / loopctl.today_date() / "demo.md" + assert handoff.exists() + handoff_text = handoff.read_text() + assert "Project Handoff: demo" in handoff_text + assert "Run these from the project owner thread" in handoff_text + assert "loop approve --project demo --approve-medium" in handoff_text + assert "Do not paste worker logs" in handoff_text assert (engine / "pm-reviews").exists() +def test_handoff_command_rebuilds_filtered_project_handoff(monkeypatch, tmp_path, capsys): + repo = make_repo(tmp_path) + engine = patch_engine(monkeypatch, tmp_path, repo) + patch_pm_agent(monkeypatch) + loopctl.write_morning_review(["demo"]) + (engine / "handoffs" / loopctl.today_date() / "demo.md").unlink() + + loopctl.handoff_command(["demo"], as_json=False) + + output = capsys.readouterr().out + assert "HANDOFF_INDEX" in output + assert "HANDOFF_PROJECT demo" in output + handoff = engine / "handoffs" / loopctl.today_date() / "demo.md" + assert handoff.exists() + assert "Success criteria" in handoff.read_text() + + def test_approve_writes_daily_focus_and_approval_artifacts(monkeypatch, tmp_path): repo = make_repo(tmp_path) engine = patch_engine(monkeypatch, tmp_path, repo)